careermate 0.0.3 → 0.0.5

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.
@@ -15,9 +15,9 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import {
17
17
  ProfileInputSchema,
18
- ExperienceInputSchema,
19
- ProjectInputSchema,
20
- SkillInputSchema,
18
+ ExperienceBatchInputSchema,
19
+ ProjectBatchInputSchema,
20
+ SkillBatchInputSchema,
21
21
  JobInputSchema,
22
22
  FitAnalysisInputSchema,
23
23
  CoverLetterVersionInputSchema,
@@ -50,9 +50,9 @@ import {
50
50
  getApplicationContext,
51
51
  saveProfile,
52
52
  addResume,
53
- addExperience,
54
- addProject,
55
- addSkill,
53
+ addExperiences,
54
+ addProjects,
55
+ addSkills,
56
56
  saveJobPosting,
57
57
  saveFitAnalysis,
58
58
  saveCoverLetterVersion,
@@ -82,6 +82,8 @@ import { resolveDashboardUrl, openInBrowser, openInFileManager } from './bridge.
82
82
  import { getUpdateStatus, getUpdateStatusAsync, runSelfUpdate } from './update.ts';
83
83
 
84
84
  const STATUS_LIST = APPLICATION_STATUSES.map((s) => `${s}(${APPLICATION_STATUS_LABELS[s]})`).join(', ');
85
+ const DELETE_CONFIRMATION = 'DELETE';
86
+ const DASHBOARD_URL = 'http://127.0.0.1:4319';
85
87
 
86
88
  /** List real, user-dropped files in the inbox (skips hidden/OS/office-lock cruft). */
87
89
  function listInboxFiles(dir: string): { name: string; path: string }[] {
@@ -278,9 +280,19 @@ export const TOOLS: ToolDef[] = [
278
280
  `더 깐깐하게 보길 원하면 "엄격하게 봐줘"라고 하거나 대시보드 설정에서 엄격 모드를 켤 수 있어요(이력서 본문에 없는 수치까지 차단).`;
279
281
  }
280
282
  if (upd.update_available) {
281
- text += `\n\n🆕 새 버전 v${upd.latest} 사용 가능 (현재 v${upd.current}). 사용자가 원하면 update_careermate 도구로 업데이트하세요.`;
283
+ text += `\n\n새 버전 v${upd.latest}이 나왔습니다(현재 v${upd.current}). 사용자가 원하면 바로 업데이트를 진행하세요. 업데이트 후에는 AI 앱 또는 MCP 연결을 재시작해야 적용됩니다.`;
282
284
  }
283
- return ok(text, { ...s, verify_strict: strict, update: upd });
285
+ return ok(text, {
286
+ ...s,
287
+ verify_strict: strict,
288
+ update: upd,
289
+ suggested_next_action: upd.update_available
290
+ ? {
291
+ prompt: `새 버전 v${upd.latest}이 나왔습니다. 지금 업데이트할까요?`,
292
+ next_tool: 'update_careermate',
293
+ }
294
+ : null,
295
+ });
284
296
  },
285
297
  },
286
298
 
@@ -343,11 +355,12 @@ export const TOOLS: ToolDef[] = [
343
355
  name: 'add_experience',
344
356
  title: '경력 추가',
345
357
  description:
346
- '구조화된 경력(직장 단위)을 별도 항목으로 저장합니다. 이력서 본문(add_resume)과 달리, 회사·직무·기간·성과를 필드로 나눠 저장해 적합도 분석/자소서 작성 시 정밀하게 활용됩니다. company는 필수이며, achievements에는 정량 지표가 담긴 성과를, tech에는 사용 기술을 배열로 넣으세요. 온보딩 완성도(get_onboarding_status)의 has_experience를 채우려면 이 도구로 1건 이상 추가해야 합니다.',
347
- inputSchema: ExperienceInputSchema.shape,
358
+ '구조화된 경력(직장 단위)을 저장합니다. 이력서에서 파악한 경력을 experiences 배열에 한 번에 담아 호출하세요(한 건이어도 배열로 — 항목마다 도구를 반복 호출하지 마세요). 이력서 본문(add_resume)과 달리 회사·직무·기간·성과를 필드로 나눠 저장해 적합도 분석/자소서 작성 시 정밀하게 활용됩니다. 각 항목은 company가 필수이며, achievements에는 정량 지표가 담긴 성과를, tech에는 사용 기술을 배열로 넣으세요. 같은 회사·직무·입사일(start_date)이 일치하면 갱신되고 중복 생성되지 않으니 여러 번 저장해도 안전합니다. 온보딩 완성도(get_onboarding_status)의 has_experience를 채우려면 1건 이상 추가하세요.',
359
+ inputSchema: ExperienceBatchInputSchema.shape,
348
360
  handler: (args) => {
349
- const exp = addExperience(args);
350
- return ok(`경력 '${exp.company}${exp.role ? ` · ${exp.role}` : ''}'을(를) 추가했습니다.`, exp);
361
+ const { records, created, updated } = addExperiences(args.experiences);
362
+ const names = records.map((e) => `${e.company}${e.role ? ` · ${e.role}` : ''}`).join(', ');
363
+ return ok(`경력 ${records.length}건을 저장했습니다 (신규 ${created} · 갱신 ${updated}): ${names}`, records);
351
364
  },
352
365
  },
353
366
  {
@@ -367,11 +380,12 @@ export const TOOLS: ToolDef[] = [
367
380
  name: 'add_project',
368
381
  title: '프로젝트 추가',
369
382
  description:
370
- '대표 프로젝트를 별도 항목으로 저장합니다. name은 필수이며, highlights(핵심 성과)와 tech(사용 기술)를 배열로 넣으면 자소서·면접 준비에서 근거로 활용됩니다. url에 결과물 링크를 넣을 수 있습니다.',
371
- inputSchema: ProjectInputSchema.shape,
383
+ '대표 프로젝트를 저장합니다. projects 배열에 여러 개를 한 번에 담아 호출하세요(한 개여도 배열로 — 항목마다 반복 호출하지 마세요). 각 항목은 name이 필수이며, highlights(핵심 성과)와 tech(사용 기술)를 배열로 넣으면 자소서·면접 준비에서 근거로 활용됩니다. url에 결과물 링크를 넣을 수 있습니다. 같은 이름의 프로젝트는 갱신되고 중복 생성되지 않으니 여러 번 저장해도 안전합니다.',
384
+ inputSchema: ProjectBatchInputSchema.shape,
372
385
  handler: (args) => {
373
- const prj = addProject(args);
374
- return ok(`프로젝트 '${prj.name}'을(를) 추가했습니다.`, prj);
386
+ const { records, created, updated } = addProjects(args.projects);
387
+ const names = records.map((p) => p.name).join(', ');
388
+ return ok(`프로젝트 ${records.length}건을 저장했습니다 (신규 ${created} · 갱신 ${updated}): ${names}`, records);
375
389
  },
376
390
  },
377
391
  {
@@ -391,11 +405,12 @@ export const TOOLS: ToolDef[] = [
391
405
  name: 'add_skill',
392
406
  title: '기술스택 추가',
393
407
  description:
394
- '보유 기술스택을 별도 항목으로 저장합니다. name은 필수이며, category(언어/프레임워크/툴/소프트스킬 등)·level·years로 숙련도를 함께 기록하면 공고 키워드 매칭이 정확해집니다. 온보딩 완성도의 has_skills를 채우려면 이 도구로 1건 이상 추가해야 합니다. 여러 개면 도구를 반복 호출하세요.',
395
- inputSchema: SkillInputSchema.shape,
408
+ '보유 기술스택을 저장합니다. 여러 개를 skills 배열에 한 번에 담아 호출하세요(한 개여도 배열로 — 기술마다 도구를 반복 호출하지 마세요). 각 항목은 name이 필수이며, category(언어/프레임워크/툴/소프트스킬 등)·level·years로 숙련도를 함께 기록하면 공고 키워드 매칭이 정확해집니다. 같은 이름의 기술은 갱신되고 중복 생성되지 않으니 여러 번 저장해도 안전합니다. 온보딩 완성도의 has_skills를 채우려면 1개 이상 추가하세요.',
409
+ inputSchema: SkillBatchInputSchema.shape,
396
410
  handler: (args) => {
397
- const skill = addSkill(args);
398
- return ok(`기술 '${skill.name}'을(를) 추가했습니다.`, skill);
411
+ const { records, created, updated } = addSkills(args.skills);
412
+ const names = records.map((s) => s.name).join(', ');
413
+ return ok(`기술 ${records.length}개를 저장했습니다 (신규 ${created} · 갱신 ${updated}): ${names}`, records);
399
414
  },
400
415
  },
401
416
  {
@@ -448,6 +463,32 @@ export const TOOLS: ToolDef[] = [
448
463
  }
449
464
  },
450
465
  },
466
+ {
467
+ name: 'delete_cover_letter',
468
+ title: '자기소개서 삭제',
469
+ description:
470
+ '사용자가 저장된 자기소개서 삭제를 명확히 요청했을 때만 사용합니다. 먼저 get_cover_letters로 전체 목록 또는 공고별 목록을 확인하고, 삭제할 항목을 사용자가 확인한 뒤 호출하세요. 삭제하면 해당 자기소개서의 모든 버전이 함께 삭제되며 되돌릴 수 없습니다. confirm에는 DELETE를 넣으세요.',
471
+ inputSchema: {
472
+ cover_letter_id: z.string(),
473
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
474
+ },
475
+ handler: (args) => {
476
+ if (args?.confirm !== DELETE_CONFIRMATION) {
477
+ return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
478
+ }
479
+ const cl = coverLetterRepo.get(args.cover_letter_id, false);
480
+ if (!cl) return fail('자기소개서를 찾을 수 없습니다.');
481
+ const removed = coverLetterRepo.remove(cl.id);
482
+ if (!removed) return fail('자기소개서를 삭제하지 못했습니다.');
483
+ const user_message = `'${cl.title}' 자기소개서를 삭제했어요. 연결된 지원 기록에서는 자기소개서 연결만 비워뒀습니다.`;
484
+ return ok(user_message, {
485
+ deleted: true,
486
+ cover_letter_id: cl.id,
487
+ title: cl.title,
488
+ user_message,
489
+ });
490
+ },
491
+ },
451
492
 
452
493
  /* ------------------------------------------------------------------ jobs */
453
494
  {
@@ -458,7 +499,15 @@ export const TOOLS: ToolDef[] = [
458
499
  inputSchema: JobInputSchema.shape,
459
500
  handler: (args) => {
460
501
  const { job } = saveJobPosting(args);
461
- return ok(`'${job.company} · ${job.position}' 공고를 저장했습니다. (job_id: ${job.id})`, job);
502
+ const user_message = `'${job.company} · ${job.position}' 공고를 저장했어요.`;
503
+ return ok(user_message, {
504
+ ...job,
505
+ user_message,
506
+ suggested_next_action: {
507
+ prompt: '이 공고와 내 프로필을 비교해 적합도를 분석할까요?',
508
+ next_workflow: 'analyze_job',
509
+ },
510
+ });
462
511
  },
463
512
  },
464
513
  {
@@ -469,7 +518,7 @@ export const TOOLS: ToolDef[] = [
469
518
  readOnly: true,
470
519
  handler: (args) => {
471
520
  const job = jobRepo.get(args.job_id);
472
- if (!job) return fail(`공고를 찾을 수 없습니다: ${args.job_id}`);
521
+ if (!job) return fail('공고를 찾을 수 없습니다.');
473
522
  return ok(`${job.company} · ${job.position}`, {
474
523
  job: jobWithMeta(job),
475
524
  fit: fitRepo.getByJob(job.id),
@@ -490,6 +539,38 @@ export const TOOLS: ToolDef[] = [
490
539
  return ok(`공고 ${jobs.length}건`, jobs);
491
540
  },
492
541
  },
542
+ {
543
+ name: 'delete_job_posting',
544
+ title: '채용공고 삭제',
545
+ description:
546
+ '사용자가 저장된 채용공고 삭제를 명확히 요청했을 때만 사용합니다. 먼저 list_jobs로 전체 공고 목록을 확인하고, 삭제할 공고를 사용자가 확인한 뒤 호출하세요. 삭제하면 연결된 지원 상태·적합도 분석·면접 준비가 함께 삭제됩니다. 연결된 자기소개서는 삭제하지 않고 공고 연결만 해제합니다. confirm에는 DELETE를 넣으세요.',
547
+ inputSchema: {
548
+ job_id: z.string(),
549
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
550
+ },
551
+ handler: (args) => {
552
+ if (args?.confirm !== DELETE_CONFIRMATION) {
553
+ return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
554
+ }
555
+ const job = jobRepo.get(args.job_id);
556
+ if (!job) return fail('공고를 찾을 수 없습니다.');
557
+ const detachedCoverLetters = coverLetterRepo.listByJob(job.id).length;
558
+ const removed = jobRepo.remove(job.id);
559
+ if (!removed) return fail('공고를 삭제하지 못했습니다.');
560
+ const detachedNote = detachedCoverLetters
561
+ ? ` 연결된 자기소개서 ${detachedCoverLetters}건은 삭제하지 않고 공고 연결만 해제했습니다.`
562
+ : '';
563
+ const user_message = `'${job.company} · ${job.position}' 공고를 삭제했어요. 연결된 지원 상태, 적합도 분석, 면접 준비도 함께 정리했습니다.${detachedNote}`;
564
+ return ok(user_message, {
565
+ deleted: true,
566
+ job_id: job.id,
567
+ company: job.company,
568
+ position: job.position,
569
+ detached_cover_letters: detachedCoverLetters,
570
+ user_message,
571
+ });
572
+ },
573
+ },
493
574
 
494
575
  /* ----------------------------------------------------- application context */
495
576
  {
@@ -517,8 +598,8 @@ export const TOOLS: ToolDef[] = [
517
598
  ctx.job ? `대상 공고: ${ctx.job.company} · ${ctx.job.position}` : '대상 공고 없음',
518
599
  ctx.writing_preferences.preferred_tone ? `선호 문체: ${ctx.writing_preferences.preferred_tone}` : '',
519
600
  recommended_route
520
- ? `💡 다음 단계: get_workflow_guide({ workflow_id: "${recommended_route}" })로 전문가 실행 절차(EOP)와 적용할 플레이북·검증 순서를 받아 적용하세요(즉답 금지).`
521
- : '💡 커리어 작업을 시작하려면 get_workflow_guide로 전문가 실행 절차와 검증 순서를 먼저 받으세요.',
601
+ ? `내부 안내: 사용자에게는 도구명이나 ID를 말하지 말고, 먼저 get_workflow_guide({ workflow_id: "${recommended_route}" })로 실행 절차와 검증 순서를 받은 뒤 쉬운 말로 안내하세요.`
602
+ : '내부 안내: 사용자에게는 도구명을 말하지 말고, 먼저 get_workflow_guide로 실행 절차와 검증 순서를 받은 뒤 쉬운 말로 안내하세요.',
522
603
  ].filter(Boolean).join('\n');
523
604
  return ok(summary, enriched);
524
605
  },
@@ -532,7 +613,34 @@ export const TOOLS: ToolDef[] = [
532
613
  handler: (args) => {
533
614
  try {
534
615
  const fit = saveFitAnalysis(args);
535
- return ok(`적합도 분석을 저장했습니다${fit.score != null ? ` (${fit.score}점)` : ''}.`, fit);
616
+ const suggested_next_action = {
617
+ prompt: '이 공고에 맞춘 자기소개서를 써드릴까요?',
618
+ options: [
619
+ {
620
+ label: '네, 이 공고에 맞춘 자기소개서를 써줘',
621
+ next_workflow: 'write_cover_letter',
622
+ },
623
+ {
624
+ label: '아니요, 분석만 저장해줘',
625
+ next_workflow: null,
626
+ },
627
+ ],
628
+ };
629
+ const user_message = `적합도 분석을 저장했어요${fit.score != null ? ` (${fit.score}점)` : ''}. 자세한 결과는 대시보드에서도 확인할 수 있습니다: ${DASHBOARD_URL}`;
630
+ return ok(
631
+ [
632
+ user_message,
633
+ '',
634
+ '사용자에게 다음 선택지를 보여주세요:',
635
+ '1. 네, 이 공고에 맞춘 자기소개서를 써줘',
636
+ '2. 아니요, 분석만 저장해줘',
637
+ ].join('\n'),
638
+ {
639
+ ...fit,
640
+ user_message,
641
+ suggested_next_action,
642
+ },
643
+ );
536
644
  } catch (e) {
537
645
  return fail(e instanceof Error ? e.message : '저장 실패');
538
646
  }
@@ -552,9 +660,21 @@ export const TOOLS: ToolDef[] = [
552
660
  handler: (args) => {
553
661
  try {
554
662
  const res = updateApplicationStatus(args.job_id, args.status, args.note);
663
+ const user_message =
664
+ `지원 상태를 '${APPLICATION_STATUS_LABELS[args.status as keyof typeof APPLICATION_STATUS_LABELS]}'(으)로 바꿨어요.` +
665
+ (res.hint ? `\n\n${res.hint}` : '');
555
666
  return ok(
556
- `상태를 '${APPLICATION_STATUS_LABELS[args.status as keyof typeof APPLICATION_STATUS_LABELS]}'(으)로 변경했습니다.${res.hint ? `\n\n💡 ${res.hint}` : ''}`,
557
- res,
667
+ user_message,
668
+ {
669
+ ...res,
670
+ user_message,
671
+ suggested_next_action: res.hint
672
+ ? {
673
+ prompt: res.hint,
674
+ next_workflow: 'prepare_interview',
675
+ }
676
+ : null,
677
+ },
558
678
  );
559
679
  } catch (e) {
560
680
  return fail(e instanceof Error ? e.message : '변경 실패');
@@ -572,7 +692,8 @@ export const TOOLS: ToolDef[] = [
572
692
  handler: (args) => {
573
693
  try {
574
694
  const prep = saveInterviewPrep(args);
575
- return ok(`면접 준비 자료를 저장했습니다 (질문 ${prep.questions.length}개).`, prep);
695
+ const user_message = `면접 준비 자료를 저장했어요. 예상 질문 ${prep.questions.length}개가 준비되어 있습니다.`;
696
+ return ok(user_message, { ...prep, user_message });
576
697
  } catch (e) {
577
698
  return fail(e instanceof Error ? e.message : '저장 실패');
578
699
  }
@@ -789,17 +910,27 @@ export const TOOLS: ToolDef[] = [
789
910
  name: 'check_for_update',
790
911
  title: '업데이트 확인',
791
912
  description:
792
- 'CareerMate에 새 버전이 있는지 npm 레지스트리에서 지금 확인합니다(공개 버전 정보만 GET하며 사용자 데이터는 전송하지 않습니다). 현재 버전·최신 버전·업데이트 가능 여부를 돌려줍니다. 사용자가 "최신이야?/업데이트 있어?"라고 묻거나, get_onboarding_status가 새 버전을 알릴 때 호출하세요. 실제 적용은 update_careermate로 합니다.',
913
+ 'CareerMate에 새 버전이 있는지 npm 레지스트리에서 지금 확인합니다(공개 버전 정보만 GET하며 사용자 데이터는 전송하지 않습니다). 현재 버전·최신 버전·업데이트 가능 여부를 돌려줍니다. 사용자가 "최신이야?/업데이트 있어?"라고 묻거나, get_onboarding_status가 새 버전을 알릴 때 호출하세요. 확인만 하고 설치는 하지 않습니다. 사용자가 "업데이트해줘/최신으로 올려줘"처럼 명시적으로 요청하면 update_careermate를 호출하세요.',
793
914
  inputSchema: {},
794
915
  readOnly: true,
795
916
  handler: async () => {
796
917
  const u = await getUpdateStatusAsync();
797
918
  if (u.latest === null) return ok('업데이트 확인에 실패했습니다(오프라인이거나 일시적 오류). 잠시 후 다시 시도하세요.', u);
919
+ const user_message = u.update_available
920
+ ? `새 버전 v${u.latest}이 나왔어요. 현재 버전은 v${u.current}입니다. 원하시면 지금 업데이트할 수 있습니다.`
921
+ : `최신 버전입니다 (v${u.current}).`;
798
922
  return ok(
799
- u.update_available
800
- ? `새 버전 v${u.latest} 사용 가능 (현재 v${u.current}). update_careermate로 업데이트할 수 있습니다.`
801
- : `최신 버전입니다 (v${u.current}).`,
802
- u,
923
+ user_message,
924
+ {
925
+ ...u,
926
+ user_message,
927
+ suggested_next_action: u.update_available
928
+ ? {
929
+ prompt: '지금 CareerMate를 업데이트할까요?',
930
+ next_tool: 'update_careermate',
931
+ }
932
+ : null,
933
+ },
803
934
  );
804
935
  },
805
936
  },
@@ -807,12 +938,12 @@ export const TOOLS: ToolDef[] = [
807
938
  name: 'update_careermate',
808
939
  title: 'CareerMate 업데이트',
809
940
  description:
810
- 'CareerMate를 최신 버전으로 업데이트합니다(설치 폴더에 npm으로 careermate@latest 설치). 사용자가 명시적으로 "업데이트해줘"라고 할 때만 호출하세요. 성공하면 적용을 위해 AI 앱(또는 MCP 연결) 재시작이 필요하며, 재시작 시 데이터베이스는 자동 마이그레이션되어 기존 데이터가 보존됩니다. 자동 설치가 막히면(개발 모드·비표준 설치) 직접 칠 명령을 안내합니다.',
941
+ 'CareerMate를 최신 버전으로 업데이트합니다(설치 폴더에 npm으로 careermate@latest 설치). 사용자가 명시적으로 "업데이트해줘", "CareerMate 업데이트해줘", "최신으로 올려줘"라고 할 때 호출하세요. 이 도구가 최신 버전 확인도 함께 하므로 별도 확인 질문 없이 실행해도 됩니다. 성공하면 적용을 위해 AI 앱(또는 MCP 연결) 재시작이 필요하며, 재시작 시 데이터베이스는 자동 마이그레이션되어 기존 데이터는 보존됩니다. 자동 설치가 막히면(개발 모드·비표준 설치) 직접 칠 명령을 안내합니다.',
811
942
  inputSchema: {},
812
943
  handler: async () => {
813
944
  const r = await runSelfUpdate();
814
945
  if (!r.ok) return fail(`${r.message}${r.manual ? `\n\n직접 실행: ${r.manual}` : ''}`);
815
- return ok(r.message, r);
946
+ return ok(r.message, { ...r, user_message: r.message });
816
947
  },
817
948
  },
818
949
  ];
@@ -325,6 +325,6 @@ export async function runSelfUpdate(): Promise<UpdateResult> {
325
325
  ok: true,
326
326
  from,
327
327
  to: latest,
328
- message: `careermate를 ${latest ? `v${latest}` : '최신 버전'}(으)로 업데이트했습니다. 적용하려면 AI 앱(또는 MCP 연결)을 재시작하세요. 재시작 시 데이터베이스는 자동으로 마이그레이션되며 기존 데이터는 보존됩니다.`,
328
+ message: `CareerMate를 ${latest ? `v${latest}` : '최신 버전'}(으)로 업데이트했습니다. 적용하려면 AI 앱(또는 MCP 연결)을 재시작하세요. 재시작 시 데이터베이스는 자동으로 마이그레이션되며 기존 데이터는 보존됩니다.`,
329
329
  };
330
330
  }
@@ -17,7 +17,7 @@ export const ONBOARDING_PROMPT = `이제 CareerMate에 연결되었습니다.
17
17
  - 이름, 연락처, 한 줄 소개(headline), 자기소개 요약(summary), 희망 직무(desired_roles), 희망 근무 조건(desired_conditions)을 정리합니다.
18
18
  - 글쓰기 선호도 함께 물어보세요: 선호 문체(preferred_tone, 예: "담백하고 구체적")와 강조하고 싶은 핵심 포인트(emphasis_points). 이 정보는 이후 자기소개서 작성에 그대로 활용됩니다.
19
19
  - 정리한 내용을 \`save_profile\`로 저장하세요. 파일에서 읽어온 정보는 source를 'upload', 사용자가 말로 알려준 정보는 'manual'로 표시하세요.
20
- - 경력·프로젝트·스킬 정보가 있으면 \`add_experience\`·\`add_project\`·\`add_skill\`로 각각 저장하세요(경력 achievements는 가능하면 정량 지표 포함). 이 항목들은 온보딩 완성도(has_experience/has_skills)에 반영됩니다.
20
+ - 경력·프로젝트·스킬 정보가 있으면 \`add_experience\`·\`add_project\`·\`add_skill\`로 저장하세요. 각 도구는 배열을 받으니 파악한 항목을 한 번에 모아 넘기세요(항목마다 반복 호출하지 마세요). 같은 항목을 다시 넣어도 중복 없이 갱신됩니다(경력 achievements는 가능하면 정량 지표 포함). 이 항목들은 온보딩 완성도(has_experience/has_skills)에 반영됩니다.
21
21
  - 확실하지 않은 정보는 지어내지 말고 사용자에게 확인하세요.
22
22
 
23
23
  # 3단계 — 이력서 등록
@@ -37,17 +37,25 @@ CareerMate는 사용자의 컴퓨터에서 동작하는 로컬 커리어 관리
37
37
 
38
38
  6. 사용자에게는 쉬운 한국어로 알립니다.
39
39
  - "MCP", "툴 호출", "job_id" 같은 기술 용어는 사용자에게 직접 노출하지 마세요. "공고를 저장했어요", "적합도를 분석했어요"처럼 일상적인 표현으로 진행 상황을 알리세요.
40
+ - 도구 응답에 \`user_message\`가 있으면 사용자에게 보고할 때 그 문장을 우선 활용하세요. \`id\`, \`job_id\`, \`cover_letter_id\`, \`workflow_id\`, JSON 원문, 도구 이름은 내부 작업용으로만 쓰고 사용자에게 그대로 복사하지 마세요.
41
+ - 도구 응답에 \`suggested_next_action\`이 있으면 다음 행동을 강요하지 말고 선택지로 제안하세요.
40
42
  - 무엇을 했고, 무엇을 알게 되었고, 다음에 무엇을 할 수 있는지 짧고 명확하게 전달하세요.
41
43
 
42
44
  7. 다음 단계를 먼저 제안합니다.
43
45
  - 작업이 끝나면 자연스러운 다음 행동을 제안하세요. 예: 공고 분석 후 → "이 공고에 맞춘 자기소개서를 써드릴까요?", 자기소개서 저장 후 → "지원 상태를 '지원 완료'로 바꿀까요?", 상태가 '서류 합격'이 되면 → "면접 준비를 도와드릴까요?".
46
+ - 공고 분석 후에는 곧장 자기소개서를 쓰지 말고, 반드시 선택지를 보여주세요: "1. 네, 이 공고에 맞춘 자기소개서를 써줘" / "2. 아니요, 분석만 저장해줘". 사용자가 1번을 고른 경우에만 자기소개서 작성 흐름으로 이어갑니다.
44
47
  - \`get_onboarding_status\`의 next_steps나 \`open_dashboard\`/\`open_application\`을 활용해 사용자를 다음 행동으로 안내하세요.
45
48
 
46
- 8. 글은 AI 티 안 나게 씁니다.
49
+ 8. 업데이트 요청은 명확하게 처리합니다.
50
+ - 사용자가 "최신이야?", "업데이트 있어?"처럼 확인만 요청하면 \`check_for_update\`를 호출하고 결과를 쉬운 말로 알려주세요.
51
+ - 사용자가 "업데이트해줘", "CareerMate 업데이트해줘", "최신으로 올려줘"처럼 적용을 명시하면 \`update_careermate\`를 바로 호출하세요. 이 도구가 최신 버전 확인도 함께 합니다.
52
+ - 업데이트 성공 후에는 적용하려면 AI 앱 또는 MCP 연결을 재시작해야 한다고 안내하세요. 사용자 커리어 데이터는 로컬에 보존되고, npm에는 공개 버전 정보 확인/설치 요청만 나갑니다.
53
+
54
+ 9. 글은 AI 티 안 나게 씁니다.
47
55
  - 자기소개서·자기 PR·지원 메일 등 사람이 쓴 듯한 글을 작성하기 직전에는 \`get_writing_style_guide\`를 호출해 글쓰기 규칙을 가져오고 그대로 적용하세요.
48
56
  - 번역투, "열정을 가지고/귀사의 발전에 기여" 같은 클리셰, 기계적 병렬(첫째·둘째·셋째), 상투적 연결어, 균일한 문장 리듬을 피하고 담백하고 구체적으로 씁니다. 단, 사실·수치·고유명사는 절대 바꾸거나 지어내지 않습니다.
49
57
 
50
- 9. 전문가 절차로 만들고, 저장 전 당신이 자가검증합니다.
58
+ 10. 전문가 절차로 만들고, 저장 전 당신이 자가검증합니다.
51
59
  - 공고 분석·자소서·면접 자료처럼 사용자 대면 결과를 만들기 전에는, 즉답하지 말고 \`get_workflow_guide\`(workflow_id 지정)를 먼저 호출해 그 작업의 전문가 실행 절차(EOP)와 적용할 플레이북·검증기 순서를 받으세요. 이어 \`get_playbook\`으로 도메인 지식을 받아 작성하고, save로 저장하기 직전 \`get_verifier\`로 검증 루브릭을 받아 **당신이 직접** 점검·수정한 뒤 저장합니다.
52
60
  - CareerMate는 슬롭 밀도·키워드 매칭처럼 셀 수 있는 항목만 보조로 돕고, 의미 판단(이 글이 질문에 답하는가·톤이 맞는가)은 당신이 합니다. 사실·수치·고유명사는 그대로 보존합니다.
53
61
 
@@ -6,6 +6,7 @@
6
6
  * Korean because the primary audience is Korean job seekers; codes are stable
7
7
  * machine values that must never change once persisted.
8
8
  */
9
+ import { conflict } from './errors.ts';
9
10
 
10
11
  /** Application lifecycle. Drives the Applications board in the dashboard. */
11
12
  export const APPLICATION_STATUSES = [
@@ -84,8 +85,12 @@ export function canTransitionStatus(from: ApplicationStatus, to: ApplicationStat
84
85
  /** Throw a clear, user-facing (Korean) error when a status transition is not allowed. */
85
86
  export function assertStatusTransition(from: ApplicationStatus, to: ApplicationStatus): void {
86
87
  if (!canTransitionStatus(from, to)) {
87
- throw new Error(
88
- `'${APPLICATION_STATUS_LABELS[from]}'에서 '${APPLICATION_STATUS_LABELS[to]}'(으)로는 상태를 바꿀 수 없습니다.`,
88
+ const suffix =
89
+ to === 'draft'
90
+ ? ` ('${APPLICATION_STATUS_LABELS[from]}' 단계의 지원은 작성 중(미추적)으로 되돌릴 수 없습니다. 초기 단계로 옮기려면 '지원 예정'을 선택하세요.)`
91
+ : '';
92
+ throw conflict(
93
+ `'${APPLICATION_STATUS_LABELS[from]}'에서 '${APPLICATION_STATUS_LABELS[to]}'(으)로는 상태를 바꿀 수 없습니다.${suffix}`,
89
94
  );
90
95
  }
91
96
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Domain errors whose message is written as user-facing Korean guidance.
3
+ *
4
+ * The split matters at the HTTP boundary: an *expected* domain failure (공고 없음,
5
+ * 금지된 상태 전환, 저장 게이트 차단 …) should reach the dashboard with its real
6
+ * message and an accurate status code, exactly like the MCP layer already relays
7
+ * `e.message` via `fail(...)`. An *unexpected* internal error (a SQLite fault, a
8
+ * bug) must stay an opaque 500 so we never leak internals. Plain `Error` can't be
9
+ * told apart from the latter, so user-facing failures throw `CareerMateError` and
10
+ * the HTTP layer maps only those to `httpStatus` + message.
11
+ */
12
+ export class CareerMateError extends Error {
13
+ constructor(
14
+ message: string,
15
+ /** HTTP status the dashboard should return (404 not-found, 409 conflict, 400 validation). */
16
+ readonly httpStatus = 400,
17
+ /** Optional machine-readable code for the client. */
18
+ readonly code?: string,
19
+ ) {
20
+ super(message);
21
+ this.name = 'CareerMateError';
22
+ }
23
+ }
24
+
25
+ /** The requested record does not exist (HTTP 404). */
26
+ export function notFound(message: string): CareerMateError {
27
+ return new CareerMateError(message, 404, 'not_found');
28
+ }
29
+
30
+ /** The action conflicts with the record's current lifecycle state (HTTP 409). */
31
+ export function conflict(message: string): CareerMateError {
32
+ return new CareerMateError(message, 409, 'conflict');
33
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export * from './enums.ts';
6
6
  export * from './schemas.ts';
7
+ export * from './errors.ts';
7
8
  export { APP_VERSION, BUNDLED } from './build-info.ts';
8
9
 
9
10
  /** Short, URL-safe, sortable-ish id. Avoids a uuid dependency. */
@@ -72,8 +72,32 @@ const reqBody = z
72
72
  .string()
73
73
  .max(MAX_BODY)
74
74
  .refine((s) => s.trim().length > 0, { message: EMPTY_MSG });
75
- /** Bounded array of bounded strings. */
76
- const strList = z.array(z.string().max(MAX_ITEM)).max(MAX_ITEMS);
75
+ /**
76
+ * Coerce a stray scalar into a string array. MCP/LLM clients routinely serialize
77
+ * array arguments as a *string* — a JSON-encoded array (`'["Java","Spring"]'`) or
78
+ * a comma/newline-separated list (`'Java, Spring'`) — instead of a real array. The
79
+ * SDK then rejects the whole tool call with "Expected array, received string", so
80
+ * a single mis-serialized field (keywords, requirements, strengths, tech, tags…)
81
+ * fails the entire save. Absorb those shapes here; real arrays and non-strings
82
+ * pass straight through. The emitted JSON schema still advertises `array`, so the
83
+ * client is still told the correct shape — this only rescues the cases it ignores.
84
+ */
85
+ function toStrList(v: unknown): unknown {
86
+ if (typeof v !== 'string') return v;
87
+ const s = v.trim();
88
+ if (!s) return [];
89
+ if (s.startsWith('[')) {
90
+ try {
91
+ const parsed = JSON.parse(s);
92
+ if (Array.isArray(parsed)) return parsed;
93
+ } catch {
94
+ /* not valid JSON — fall through to delimiter split */
95
+ }
96
+ }
97
+ return s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
98
+ }
99
+ /** Bounded array of bounded strings; tolerant of stringified inputs (see toStrList). */
100
+ const strList = z.preprocess(toStrList, z.array(z.string().max(MAX_ITEM)).max(MAX_ITEMS));
77
101
 
78
102
  const baseRecord = {
79
103
  id,
@@ -151,6 +175,16 @@ export const ExperienceRecordSchema = z.object({
151
175
  });
152
176
  export type ExperienceRecord = z.infer<typeof ExperienceRecordSchema>;
153
177
 
178
+ /** Batch input: add several experiences in one MCP call (one item → array of one). */
179
+ export const ExperienceBatchInputSchema = z.object({
180
+ experiences: z
181
+ .array(ExperienceInputSchema)
182
+ .min(1)
183
+ .max(MAX_ITEMS)
184
+ .describe('추가할 경력 목록(한 건이어도 배열로). 같은 회사·직무·입사일은 갱신되고 중복 생성되지 않습니다.'),
185
+ });
186
+ export type ExperienceBatchInput = z.infer<typeof ExperienceBatchInputSchema>;
187
+
154
188
  /* ------------------------------------------------------------------ Project */
155
189
 
156
190
  export const ProjectInputSchema = z.object({
@@ -180,6 +214,16 @@ export const ProjectRecordSchema = z.object({
180
214
  });
181
215
  export type ProjectRecord = z.infer<typeof ProjectRecordSchema>;
182
216
 
217
+ /** Batch input: add several projects in one MCP call (one item → array of one). */
218
+ export const ProjectBatchInputSchema = z.object({
219
+ projects: z
220
+ .array(ProjectInputSchema)
221
+ .min(1)
222
+ .max(MAX_ITEMS)
223
+ .describe('추가할 프로젝트 목록(한 개여도 배열로). 같은 이름의 프로젝트는 갱신되고 중복 생성되지 않습니다.'),
224
+ });
225
+ export type ProjectBatchInput = z.infer<typeof ProjectBatchInputSchema>;
226
+
183
227
  /* -------------------------------------------------------------------- Skill */
184
228
 
185
229
  export const SkillInputSchema = z.object({
@@ -201,6 +245,16 @@ export const SkillRecordSchema = z.object({
201
245
  });
202
246
  export type SkillRecord = z.infer<typeof SkillRecordSchema>;
203
247
 
248
+ /** Batch input: add several skills in one MCP call (one item → array of one). */
249
+ export const SkillBatchInputSchema = z.object({
250
+ skills: z
251
+ .array(SkillInputSchema)
252
+ .min(1)
253
+ .max(MAX_ITEMS)
254
+ .describe('추가할 기술스택 목록(한 개여도 배열로). 같은 이름의 기술은 갱신되고 중복 생성되지 않습니다.'),
255
+ });
256
+ export type SkillBatchInput = z.infer<typeof SkillBatchInputSchema>;
257
+
204
258
  /* ----------------------------------------------------------------- Document */
205
259
 
206
260
  export const DocumentInputSchema = z.object({
@@ -33,7 +33,7 @@ export const WORKFLOWS: WorkflowDefinition[] = [
33
33
  '파일 입력 분기: ① 사용자가 "파일이 있다"고 하면 `open_inbox`로 인입 폴더를 열어 파일을 넣게 하고, "다 넣었다"고 하면 `read_inbox`로 본문을 읽는다(pdf·이미지는 read_inbox가 경로만 주므로 클라이언트의 파일 읽기로 직접 읽는다). ② 사용자가 파일 경로를 직접 알려주면 `read_document`로 읽는다. ③ "없다/직접 입력/텍스트로 붙여넣기"면 폴더를 열지 말고 받은 텍스트를 그대로 쓴다. (묻지도 않고 open_inbox로 폴더를 자동으로 열지 않는다.)',
34
34
  '수집한 정보를 구조화해 `save_profile`로 저장한다(이름·연락처·headline·summary·desired_roles·desired_conditions, 그리고 글쓰기 선호인 preferred_tone과 emphasis_points 포함). 파일 출처는 source=upload, 직접 입력은 manual.',
35
35
  '업로드한 이력서/경력기술서/포트폴리오 본문을 `add_resume`로 저장한다(kind·title 지정, 대표 문서는 is_primary=true).',
36
- '경력·프로젝트·스킬이 파악되면 `add_experience`·`add_project`·`add_skill`로 각각 저장한다(완성도의 has_experience/has_skills에 반영).',
36
+ '경력·프로젝트·스킬이 파악되면 `add_experience`·`add_project`·`add_skill`로 저장한다 — 각 도구는 배열을 받으므로 파악한 항목을 한 번에 모아 넘긴다(항목마다 반복 호출하지 않는다). 같은 항목을 다시 넣어도 중복 없이 갱신된다(완성도의 has_experience/has_skills에 반영).',
37
37
  '기존 자기소개서가 있으면 `save_cover_letter_version`으로 저장한다(title+content면 v1로 저장, 없으면 나중에 작성 가능함을 안내).',
38
38
  '`open_dashboard`를 호출해 사용자가 자기 데이터를 확인하게 한다.',
39
39
  '다시 `get_onboarding_status`로 완성도를 확인하고, 다음 단계(공고 분석 등)를 제안한다.',
@@ -53,8 +53,8 @@ export const WORKFLOWS: WorkflowDefinition[] = [
53
53
  '아직 저장되지 않은 공고라면 `save_job_posting`으로 공고를 저장하고 job_id를 확보한다.',
54
54
  '도출한 분석 결과를 `save_fit_analysis`로 저장한다(job_id, score, summary, strengths, gaps, matched_keywords, missing_keywords, recommendations).',
55
55
  '분석 결과를 사용자에게 쉬운 한국어로 설명한다(잘 맞는 점, 보완할 점, 추천 전략). 기술 용어는 빼고 전달한다.',
56
- '"이 공고에 맞춘 자기소개서를 써드릴까요?"라고 자기소개서 작성을 제안한다.',
57
- '사용자가 동의하면 자기소개서를 작성하기 직전에 `get_writing_style_guide`를 호출해 "AI 티 안 나는 글쓰기 규칙"을 가져온다.',
56
+ '분석 뒤 곧장 자기소개서를 쓰지 말고, "이 공고에 맞춘 자기소개서를 써드릴까요?"라고 묻고 선택지를 제시한다: ① "네, 이 공고에 맞춘 자기소개서를 써줘" ② "아니요, 분석만 저장해줘".',
57
+ '사용자가 ①을 선택하면 자기소개서를 작성하기 직전에 `get_writing_style_guide`를 호출해 "AI 티 안 나는 글쓰기 규칙"을 가져온다. ②를 선택하면 자소서 작성 없이 멈추고 저장 결과와 대시보드 확인만 안내한다.',
58
58
  '적합도 분석·글쓰기 선호와 글쓰기 규칙을 함께 반영해 자기소개서를 작성하고(번역투·클리셰·기계적 병렬 제거, 사실·수치는 그대로), `save_cover_letter_version`으로 저장한다(job_id 연결, note에 작성 의도 요약).',
59
59
  '`open_application`으로 해당 지원 건을 열어 사용자가 결과를 확인하게 하고, 지원 상태 변경(예: 지원 예정/지원 완료)을 제안한다.',
60
60
  ],