careermate 0.0.8 → 0.0.9

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.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +78 -81
  4. package/bin/careermate.mjs +11 -2
  5. package/dist/career-os/eop/cover-letter.md +10 -4
  6. package/dist/career-os/eop/interview-prep.md +1 -0
  7. package/dist/career-os/knowledge/cover-letter.md +4 -0
  8. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  9. package/dist/doctor.mjs +888 -340
  10. package/dist/mcp.mjs +904 -350
  11. package/dist/migrate.mjs +208 -16
  12. package/dist/public/i18n/en/applications.js +19 -0
  13. package/dist/public/i18n/en/common.js +6 -0
  14. package/dist/public/i18n/en/jobs.js +17 -0
  15. package/dist/public/i18n/en/settings.js +5 -2
  16. package/dist/public/i18n/ko/applications.js +19 -0
  17. package/dist/public/i18n/ko/common.js +5 -0
  18. package/dist/public/i18n/ko/jobs.js +17 -0
  19. package/dist/public/i18n/ko/settings.js +5 -2
  20. package/dist/public/lib.js +29 -0
  21. package/dist/public/pages/applications.js +86 -10
  22. package/dist/public/pages/documents.js +16 -7
  23. package/dist/public/pages/home.js +12 -0
  24. package/dist/public/pages/interview.js +11 -4
  25. package/dist/public/pages/jobs.js +71 -1
  26. package/dist/public/pages/settings.js +2 -2
  27. package/dist/public/styles.css +13 -0
  28. package/dist/web.mjs +544 -199
  29. package/docs/FAQ.md +13 -3
  30. package/docs/MCP_TOOLS.md +22 -9
  31. package/docs/career-os/eop/cover-letter.md +10 -4
  32. package/docs/career-os/eop/interview-prep.md +1 -0
  33. package/docs/career-os/knowledge/cover-letter.md +4 -0
  34. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  35. package/package.json +3 -1
  36. package/packages/core/src/index.ts +1 -0
  37. package/packages/core/src/jobsearch.ts +6 -2
  38. package/packages/core/src/offers.ts +41 -0
  39. package/packages/core/src/services.ts +5 -1
  40. package/packages/core/src/summary.ts +245 -19
  41. package/packages/db/src/connection.ts +70 -2
  42. package/packages/db/src/index.ts +1 -0
  43. package/packages/db/src/repositories.ts +140 -8
  44. package/packages/db/src/schema.ts +140 -8
  45. package/packages/mcp-tools/src/result.ts +9 -0
  46. package/packages/mcp-tools/src/tools.ts +143 -20
  47. package/packages/parsers/src/documents.ts +7 -1
  48. package/packages/prompts/src/humanize.ts +4 -0
  49. package/packages/shared/src/schemas.ts +134 -0
  50. package/scripts/init.ts +2 -0
@@ -21,11 +21,14 @@ import {
21
21
  type CoverLetterVersionRecord,
22
22
  type JobInput,
23
23
  type JobRecord,
24
+ type OfferInput,
25
+ type OfferRecord,
24
26
  type FitAnalysisInput,
25
27
  type FitAnalysisRecord,
26
28
  type ApplicationInput,
27
29
  type ApplicationRecord,
28
30
  type ApplicationStatus,
31
+ type RejectedStage,
29
32
  type InterviewPrepInput,
30
33
  type InterviewPrepRecord,
31
34
  type ActivityRecord,
@@ -659,6 +662,9 @@ function mapJob(r: any): JobRecord {
659
662
  company_overview: r.company_overview ?? null,
660
663
  talent_profile: r.talent_profile ?? null,
661
664
  core_values: fromJson(r.core_values, []),
665
+ reputation: r.reputation ?? null,
666
+ questions: fromJson(r.questions, []),
667
+ schedule: fromJson(r.schedule, []),
662
668
  created_at: r.created_at,
663
669
  updated_at: r.updated_at,
664
670
  };
@@ -691,7 +697,7 @@ export const jobRepo = {
691
697
  if (existing) {
692
698
  const m = { ...existing, ...input } as JobRecord;
693
699
  db.prepare(
694
- `UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,company_overview=?,talent_profile=?,core_values=?,updated_at=? WHERE id=?`,
700
+ `UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,company_overview=?,talent_profile=?,core_values=?,reputation=?,questions=?,schedule=?,updated_at=? WHERE id=?`,
695
701
  ).run(
696
702
  m.company,
697
703
  m.position,
@@ -706,6 +712,12 @@ export const jobRepo = {
706
712
  m.company_overview ?? null,
707
713
  m.talent_profile ?? null,
708
714
  toJson(m.core_values ?? []),
715
+ m.reputation ?? null,
716
+ // questions omitted from input → m keeps existing.questions (preserved);
717
+ // provided → replaced. Same merge semantics as the other JSON fields.
718
+ toJson(m.questions ?? []),
719
+ // schedule follows the same preserve-on-omit merge (v11 전형 일정).
720
+ toJson(m.schedule ?? []),
709
721
  ts,
710
722
  existing.id,
711
723
  );
@@ -713,8 +725,8 @@ export const jobRepo = {
713
725
  }
714
726
  const newJobId = id ?? newId('job_');
715
727
  db.prepare(
716
- `INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,company_overview,talent_profile,core_values,created_at,updated_at)
717
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
728
+ `INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,company_overview,talent_profile,core_values,reputation,questions,schedule,created_at,updated_at)
729
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
718
730
  ).run(
719
731
  newJobId,
720
732
  input.company,
@@ -730,6 +742,9 @@ export const jobRepo = {
730
742
  input.company_overview ?? null,
731
743
  input.talent_profile ?? null,
732
744
  toJson(input.core_values ?? []),
745
+ input.reputation ?? null,
746
+ toJson(input.questions ?? []),
747
+ toJson(input.schedule ?? []),
733
748
  ts,
734
749
  ts,
735
750
  );
@@ -744,6 +759,9 @@ export const jobRepo = {
744
759
  db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
745
760
  db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
746
761
  db.prepare(`DELETE FROM application_timeline_events WHERE job_id=?`).run(id);
762
+ // offers has ON DELETE CASCADE, but delete explicitly like the siblings above
763
+ // (consistent pattern + safe even if PRAGMA foreign_keys is ever off).
764
+ db.prepare(`DELETE FROM offers WHERE job_id=?`).run(id);
747
765
  return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id).changes > 0;
748
766
  });
749
767
  },
@@ -827,6 +845,8 @@ function mapApplication(r: any): ApplicationRecord {
827
845
  cover_letter_id: r.cover_letter_id,
828
846
  applied_at: r.applied_at,
829
847
  notes: r.notes,
848
+ // 탈락 회고: 탈락이 아니거나 미기록이면 NULL(v10 이전 DB는 컬럼이 없어 undefined → null 정규화).
849
+ rejected_stage: r.rejected_stage ?? null,
830
850
  created_at: r.created_at,
831
851
  updated_at: r.updated_at,
832
852
  };
@@ -869,25 +889,39 @@ export const applicationRepo = {
869
889
  // A status change through upsert must respect the same lifecycle gate as setStatus.
870
890
  if (input.status && input.status !== app.status) assertStatusTransition(app.status, input.status);
871
891
  const m = { ...app, ...input } as ApplicationRecord;
892
+ // rejected_stage is only meaningful while status is 'rejected' (same invariant as
893
+ // setStatus): clear it on any transition back out, preserve it when staying
894
+ // rejected. ApplicationInput carries no rejected_stage, so m keeps the stored value.
895
+ const rejectedStageValue = m.status === 'rejected' ? m.rejected_stage ?? null : null;
872
896
  getDb()
873
897
  .prepare(
874
- `UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,updated_at=? WHERE id=?`,
898
+ `UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,rejected_stage=?,updated_at=? WHERE id=?`,
875
899
  )
876
900
  // Coerce undefined → null: node:sqlite rejects undefined bindings, and a
877
901
  // caller that spreads explicit `undefined` fields (e.g. updateApplicationStatus
878
902
  // with a doc-less submission) would otherwise crash here mid-write.
879
- .run(m.status, m.resume_id ?? null, m.cover_letter_id ?? null, m.applied_at ?? null, m.notes ?? null, now(), app.id);
903
+ .run(m.status, m.resume_id ?? null, m.cover_letter_id ?? null, m.applied_at ?? null, m.notes ?? null, rejectedStageValue, now(), app.id);
880
904
  return this.get(app.id)!;
881
905
  },
882
- setStatus(jobId: string, status: ApplicationStatus, note?: string): ApplicationRecord {
906
+ setStatus(
907
+ jobId: string,
908
+ status: ApplicationStatus,
909
+ note?: string,
910
+ rejectedStage?: RejectedStage,
911
+ ): ApplicationRecord {
883
912
  const app = this.ensure(jobId);
884
913
  assertStatusTransition(app.status, status);
885
914
  const appliedAt =
886
915
  status === 'applied' && !app.applied_at ? now() : app.applied_at;
887
916
  const notes = note ? [app.notes, note].filter(Boolean).join('\n') : app.notes;
917
+ // 탈락 회고: 'rejected'일 때만 단계를 저장한다. 명시 값이 오면 갱신하고, 없으면 기존 값을
918
+ // 보존해(같은 rejected에 메모만 추가하는 등) 이미 남긴 회고를 지우지 않는다. rejected에서 다른
919
+ // 상태로 되살아나면 NULL로 초기화한다(정합성 — 탈락이 아닌데 단계가 남아 있으면 안 됨).
920
+ const rejectedStageValue =
921
+ status === 'rejected' ? rejectedStage ?? app.rejected_stage ?? null : null;
888
922
  getDb()
889
- .prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, updated_at=? WHERE id=?`)
890
- .run(status, appliedAt, notes, now(), app.id);
923
+ .prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, rejected_stage=?, updated_at=? WHERE id=?`)
924
+ .run(status, appliedAt, notes, rejectedStageValue, now(), app.id);
891
925
  return this.get(app.id)!;
892
926
  },
893
927
  };
@@ -1035,6 +1069,102 @@ export const timelineRepo = {
1035
1069
  },
1036
1070
  };
1037
1071
 
1072
+ /* -------------------------------------------------------------------- Offers */
1073
+
1074
+ function mapOffer(r: any): OfferRecord {
1075
+ const base = r.base_salary ?? null;
1076
+ const bonus = r.bonus_amount ?? null;
1077
+ const welfare = r.welfare_amount ?? null;
1078
+ // Annual cash estimate = base + bonus + welfare (signing is one-time → excluded).
1079
+ // null when no numeric cash piece was provided (can't estimate).
1080
+ const hasAny = base != null || bonus != null || welfare != null;
1081
+ const total = hasAny ? (base ?? 0) + (bonus ?? 0) + (welfare ?? 0) : null;
1082
+ return {
1083
+ id: r.id,
1084
+ job_id: r.job_id,
1085
+ offer_status: r.offer_status ?? 'open',
1086
+ base_salary: base,
1087
+ bonus_amount: bonus,
1088
+ welfare_amount: welfare,
1089
+ signing_amount: r.signing_amount ?? null,
1090
+ equity_note: r.equity_note ?? null,
1091
+ comp_note: r.comp_note ?? null,
1092
+ work_arrangement: r.work_arrangement ?? null,
1093
+ contract_type: r.contract_type ?? null,
1094
+ accept_deadline: r.accept_deadline ?? null,
1095
+ ai_score: r.ai_score ?? null,
1096
+ verdict: r.verdict ?? null,
1097
+ notes: r.notes ?? null,
1098
+ total_comp_annual_est: total,
1099
+ created_at: r.created_at,
1100
+ updated_at: r.updated_at,
1101
+ };
1102
+ }
1103
+
1104
+ export const offerRepo = {
1105
+ getByJob(jobId: string): OfferRecord | null {
1106
+ const r = getDb().prepare(`SELECT * FROM offers WHERE job_id=?`).get(jobId);
1107
+ return r ? mapOffer(r) : null;
1108
+ },
1109
+ list(): OfferRecord[] {
1110
+ return (getDb().prepare(`SELECT * FROM offers ORDER BY ai_score IS NULL, ai_score DESC, updated_at DESC`).all() as any[]).map(mapOffer);
1111
+ },
1112
+ /** Upsert by job_id (one offer per job). Missing fields are preserved on update (partial-safe). */
1113
+ save(input: OfferInput): OfferRecord {
1114
+ const db = getDb();
1115
+ const existing = this.getByJob(input.job_id);
1116
+ const ts = now();
1117
+ const n = (v: number | undefined, prev: number | null | undefined) => (v ?? prev ?? null);
1118
+ const s = (v: string | undefined, prev: string | null | undefined) => (v ?? prev ?? null);
1119
+ if (existing) {
1120
+ db.prepare(
1121
+ `UPDATE offers SET offer_status=?,base_salary=?,bonus_amount=?,welfare_amount=?,signing_amount=?,equity_note=?,comp_note=?,work_arrangement=?,contract_type=?,accept_deadline=?,ai_score=?,verdict=?,notes=?,updated_at=? WHERE id=?`,
1122
+ ).run(
1123
+ input.offer_status ?? existing.offer_status,
1124
+ n(input.base_salary, existing.base_salary),
1125
+ n(input.bonus_amount, existing.bonus_amount),
1126
+ n(input.welfare_amount, existing.welfare_amount),
1127
+ n(input.signing_amount, existing.signing_amount),
1128
+ s(input.equity_note, existing.equity_note),
1129
+ s(input.comp_note, existing.comp_note),
1130
+ s(input.work_arrangement, existing.work_arrangement),
1131
+ s(input.contract_type, existing.contract_type),
1132
+ s(input.accept_deadline, existing.accept_deadline),
1133
+ n(input.ai_score, existing.ai_score),
1134
+ s(input.verdict, existing.verdict),
1135
+ s(input.notes, existing.notes),
1136
+ ts,
1137
+ existing.id,
1138
+ );
1139
+ return this.getByJob(input.job_id)!;
1140
+ }
1141
+ const id = newId('ofr_');
1142
+ db.prepare(
1143
+ `INSERT INTO offers (id,job_id,offer_status,base_salary,bonus_amount,welfare_amount,signing_amount,equity_note,comp_note,work_arrangement,contract_type,accept_deadline,ai_score,verdict,notes,created_at,updated_at)
1144
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
1145
+ ).run(
1146
+ id,
1147
+ input.job_id,
1148
+ input.offer_status ?? 'open',
1149
+ input.base_salary ?? null,
1150
+ input.bonus_amount ?? null,
1151
+ input.welfare_amount ?? null,
1152
+ input.signing_amount ?? null,
1153
+ input.equity_note ?? null,
1154
+ input.comp_note ?? null,
1155
+ input.work_arrangement ?? null,
1156
+ input.contract_type ?? null,
1157
+ input.accept_deadline ?? null,
1158
+ input.ai_score ?? null,
1159
+ input.verdict ?? null,
1160
+ input.notes ?? null,
1161
+ ts,
1162
+ ts,
1163
+ );
1164
+ return this.getByJob(input.job_id)!;
1165
+ },
1166
+ };
1167
+
1038
1168
  /* ------------------------------------------------------------------ Counts */
1039
1169
 
1040
1170
  /**
@@ -1058,6 +1188,7 @@ export function getEntityCounts(): Record<string, number> {
1058
1188
  'applications',
1059
1189
  'interview_preps',
1060
1190
  'application_timeline_events',
1191
+ 'offers',
1061
1192
  ]);
1062
1193
  const c = (t: string): number => {
1063
1194
  if (!ALLOWED_TABLES.has(t)) throw new Error(`getEntityCounts: 허용되지 않은 테이블 이름: ${t}`);
@@ -1074,5 +1205,6 @@ export function getEntityCounts(): Record<string, number> {
1074
1205
  applications: c('applications'),
1075
1206
  interview_preps: c('interview_preps'),
1076
1207
  application_timeline_events: c('application_timeline_events'),
1208
+ offers: c('offers'),
1077
1209
  };
1078
1210
  }
@@ -5,6 +5,16 @@
5
5
  * in `_meta`. Applying is idempotent and safe to run on every process start, so
6
6
  * both the web server and the MCP server can call `migrate()` on boot and share
7
7
  * the same database file without coordination.
8
+ *
9
+ * Crash safety (this DB is the user's only copy):
10
+ * - Each migration runs in its own transaction and `schema_version` is stamped
11
+ * inside that same transaction, so a step either fully applies (including its
12
+ * version bump) or fully rolls back — a process death mid-upgrade can never
13
+ * leave "applied but unrecorded" work that re-throws on the next boot.
14
+ * - `ALTER TABLE … ADD COLUMN` statements are skipped when the column already
15
+ * exists (SQLite has no IF NOT EXISTS for columns), so a DB damaged by the
16
+ * pre-hardening runner self-heals instead of crash-looping on boot.
17
+ * - A DB stamped by a NEWER CareerMate is left untouched (never stamped down).
8
18
  */
9
19
  import type { DatabaseSync } from 'node:sqlite';
10
20
 
@@ -329,6 +339,67 @@ export const MIGRATIONS: string[] = [
329
339
  `
330
340
  ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
331
341
  `,
342
+
343
+ // v7 — offer comparison (한국 총보상 필드) + company reputation research field on jobs.
344
+ // offers: one per job (UNIQUE job_id, mirrors interview_preps). Cash pieces are numeric (만원) so
345
+ // the server can deterministically sum an annual estimate; equity/other stay free-text (comp is
346
+ // messy). ai_score/verdict are written by the connected AI at save time (no LLM here) and rendered
347
+ // by the dashboard, so it never needs to call an LLM. jobs.reputation: AI-researched company
348
+ // reputation summary (same pattern as company_overview) — feeds offer verdicts + offer-evaluation.
349
+ `
350
+ CREATE TABLE IF NOT EXISTS offers (
351
+ id TEXT PRIMARY KEY,
352
+ job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
353
+ base_salary INTEGER,
354
+ bonus_amount INTEGER,
355
+ welfare_amount INTEGER,
356
+ signing_amount INTEGER,
357
+ equity_note TEXT,
358
+ comp_note TEXT,
359
+ work_arrangement TEXT,
360
+ contract_type TEXT,
361
+ accept_deadline TEXT,
362
+ ai_score INTEGER,
363
+ verdict TEXT,
364
+ notes TEXT,
365
+ created_at TEXT NOT NULL,
366
+ updated_at TEXT NOT NULL
367
+ );
368
+ CREATE INDEX IF NOT EXISTS idx_offers_job ON offers(job_id);
369
+ ALTER TABLE jobs ADD COLUMN reputation TEXT;
370
+ `,
371
+
372
+ // v8 — offer lifecycle status. 지원 상태(전형 깔때기 = 회사의 프로세스)와 직교하는
373
+ // "내 응답" 축: open(진행)/accepted(수락)/declined(거절). declined여도 행을 지우지
374
+ // 않는다 — 조건(연봉·처우) 히스토리는 다음 협상의 자산이라, 대시보드가 접힌
375
+ // '지난 오퍼'로 강등해 보여줄 뿐이다. Additive ALTER (runner가 멱등 처리).
376
+ `
377
+ ALTER TABLE offers ADD COLUMN offer_status TEXT NOT NULL DEFAULT 'open';
378
+ `,
379
+
380
+ // v9 — 한국 문항형 자소서: 회사가 지정한 문항·글자수를 공고에 구조화 보관. 한국 자소서는
381
+ // 회사별 문항형(항목·글자수 제한)이 지배적이라, AI가 문항별로 답변을 쓰고 글자수를 점검할 때
382
+ // 참조할 원본을 공고 옆에 둔다(별도 엔티티가 아니라 job 컨텍스트에 함께 실린다). JSON 배열을
383
+ // TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
384
+ `
385
+ ALTER TABLE jobs ADD COLUMN questions TEXT NOT NULL DEFAULT '[]';
386
+ `,
387
+
388
+ // v10 — 탈락 회고: 어느 전형 단계에서 떨어졌는지 자가 기록. 탈락 사유를 고지하는 기업은 6%뿐이고
389
+ // 지원자 83%가 알고 싶어하므로, 이 자가 기록이 "서류는 붙는데 면접에서 떨어진다" 같은 패턴을 로컬
390
+ // 데이터로 드러내는 유일한 신호다. 탈락이 아니거나 미기록이면 NULL. Additive ALTER (runner가 멱등 처리).
391
+ `
392
+ ALTER TABLE applications ADD COLUMN rejected_stage TEXT;
393
+ `,
394
+
395
+ // v11 — 전형 일정: 서류마감→인적성·AI역검→1·2차 면접→발표로 이어지는 단계별 날짜를 공고에
396
+ // 구조화 보관. 일정을 jobs에 두는 이유: 서류마감(deadline)이 이미 jobs에 있고, save_job_posting
397
+ // 업서트를 그대로 재사용해 도구 신설 없이 갱신할 수 있다. 면접초대 메일·발표 공지를 사용자가
398
+ // 붙여넣으면 AI가 날짜를 추출해 저장하고, 서버가 D-day·같은 날짜 충돌을 결정론으로 계산한다.
399
+ // JSON 배열을 TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
400
+ `
401
+ ALTER TABLE jobs ADD COLUMN schedule TEXT NOT NULL DEFAULT '[]';
402
+ `,
332
403
  ];
333
404
 
334
405
  export function migrate(db: DatabaseSync): { from: number; to: number } {
@@ -338,16 +409,77 @@ export function migrate(db: DatabaseSync): { from: number; to: number } {
338
409
  | undefined;
339
410
  const from = row ? Number(row.value) : 0;
340
411
 
412
+ // DB stamped by a newer CareerMate than this code: leave it alone. Stamping it
413
+ // back down would make the newer app re-run migrations it already applied.
414
+ if (from >= MIGRATIONS.length) return { from, to: from };
415
+
341
416
  for (let v = from; v < MIGRATIONS.length; v++) {
342
- db.exec(MIGRATIONS[v]!);
417
+ applyMigration(db, v);
418
+ }
419
+ return { from, to: MIGRATIONS.length };
420
+ }
421
+
422
+ function stampVersion(db: DatabaseSync, version: number): void {
423
+ db.prepare(
424
+ `INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
425
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
426
+ ).run(String(version));
427
+ }
428
+
429
+ function columnExists(db: DatabaseSync, table: string, column: string): boolean {
430
+ const rows = db
431
+ .prepare(`PRAGMA table_info("${table.replaceAll('"', '""')}")`)
432
+ .all() as Array<{ name: string }>;
433
+ return rows.some((r) => r.name === column);
434
+ }
435
+
436
+ // Migrations that manage their own BEGIN/COMMIT, by zero-based index into MIGRATIONS.
437
+ // Only v2 (index 1) does: its FK rebuild toggles PRAGMA foreign_keys, a no-op inside
438
+ // an open transaction, so the runner must NOT wrap it. Register any future
439
+ // self-transactional migration here (a plain `sql.includes('BEGIN;')` sniff would
440
+ // misfire if a normal migration ever contained the substring, e.g. in a comment).
441
+ const SELF_TRANSACTIONAL_MIGRATIONS = new Set<number>([1]);
442
+
443
+ // Style contract for column-adding migrations: every `ALTER TABLE <t> ADD COLUMN <c>
444
+ // …;` MUST be a single statement on its own line so this regex can find it and the
445
+ // idempotent-runner can drop it when the column already exists. A multi-column or
446
+ // inline-with-other-DDL ALTER would slip past skipExistingColumns and crash a
447
+ // partial-migration re-run. scripts/test-migrate.ts guards this with a count check
448
+ // (loose ALTER…ADD COLUMN scan === strict per-line matches). Keep new ALTERs one-per-line.
449
+ const ALTER_ADD_COLUMN_RE = /^[ \t]*ALTER TABLE\s+(\w+)\s+ADD COLUMN\s+(\w+)[^;]*;[ \t]*$/gm;
450
+ export { ALTER_ADD_COLUMN_RE };
451
+
452
+ /** Drop ADD COLUMN statements whose column already exists (idempotent re-run). */
453
+ function skipExistingColumns(db: DatabaseSync, sql: string): string {
454
+ return sql.replace(ALTER_ADD_COLUMN_RE, (stmt, table: string, column: string) =>
455
+ columnExists(db, table, column) ? '' : stmt,
456
+ );
457
+ }
458
+
459
+ function applyMigration(db: DatabaseSync, v: number): void {
460
+ const sql = MIGRATIONS[v]!;
461
+
462
+ if (SELF_TRANSACTIONAL_MIGRATIONS.has(v)) {
463
+ // Self-transactional migration (v2 toggles PRAGMA foreign_keys, which is a
464
+ // no-op inside an open transaction, so it must manage BEGIN/COMMIT itself).
465
+ // Stamp right after; if the process dies in the tiny window between the two
466
+ // statements, re-running the rebuild is safe (it copies the data again).
467
+ db.exec(sql);
468
+ stampVersion(db, v + 1);
469
+ return;
343
470
  }
344
471
 
345
- const to = MIGRATIONS.length;
346
- if (to !== from) {
347
- db.prepare(
348
- `INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
349
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
350
- ).run(String(to));
472
+ db.exec('BEGIN');
473
+ try {
474
+ db.exec(skipExistingColumns(db, sql));
475
+ stampVersion(db, v + 1);
476
+ db.exec('COMMIT');
477
+ } catch (err) {
478
+ try {
479
+ db.exec('ROLLBACK');
480
+ } catch {
481
+ /* surface the original error */
482
+ }
483
+ throw err;
351
484
  }
352
- return { from, to };
353
485
  }
@@ -22,6 +22,15 @@ export interface ToolDef {
22
22
  inputSchema: ZodRawShape;
23
23
  /** Hint flags for clients (readOnly tools don't mutate). */
24
24
  readOnly?: boolean;
25
+ /**
26
+ * Tool reaches outside the user's machine (job-board fetch, npm registry).
27
+ * Must be honest — the privacy story depends on these hints being true.
28
+ */
29
+ openWorld?: boolean;
30
+ /** Irreversible effect (hard delete, package update). Default: non-destructive upsert. */
31
+ destructive?: boolean;
32
+ /** Repeating the same call with the same args has no additional effect (documented upserts). */
33
+ idempotent?: boolean;
25
34
  handler: (args: any) => ToolResult | Promise<ToolResult>;
26
35
  }
27
36