careermate 0.0.4 → 0.0.6

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 (124) hide show
  1. package/AGENTS.md +11 -7
  2. package/INSTALL.md +18 -16
  3. package/README.md +28 -26
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +8 -3
  7. package/dist/career-os/eop/career-description.md +136 -0
  8. package/dist/career-os/eop/cover-letter.md +7 -5
  9. package/dist/career-os/eop/fit-analysis.md +15 -5
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +5 -3
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +4 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  16. package/dist/career-os/knowledge/interview-technical.md +106 -4
  17. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  18. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  19. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  20. package/dist/career-os/knowledge/portfolio.md +3 -3
  21. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  22. package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
  23. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  24. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  25. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  26. package/dist/doctor.mjs +1019 -127
  27. package/dist/mcp.mjs +1036 -126
  28. package/dist/migrate.mjs +52 -1
  29. package/dist/public/app.js +42 -16
  30. package/dist/public/i18n/en/applications.js +23 -0
  31. package/dist/public/i18n/en/common.js +22 -0
  32. package/dist/public/i18n/en/documents.js +133 -0
  33. package/dist/public/i18n/en/home.js +75 -0
  34. package/dist/public/i18n/en/interview.js +69 -0
  35. package/dist/public/i18n/en/jobs.js +152 -0
  36. package/dist/public/i18n/en/nav.js +18 -0
  37. package/dist/public/i18n/en/profile.js +201 -0
  38. package/dist/public/i18n/en/settings.js +127 -0
  39. package/dist/public/i18n/en/status.js +22 -0
  40. package/dist/public/i18n/en.js +17 -0
  41. package/dist/public/i18n/ko/applications.js +23 -0
  42. package/dist/public/i18n/ko/common.js +22 -0
  43. package/dist/public/i18n/ko/documents.js +133 -0
  44. package/dist/public/i18n/ko/home.js +63 -0
  45. package/dist/public/i18n/ko/interview.js +69 -0
  46. package/dist/public/i18n/ko/jobs.js +152 -0
  47. package/dist/public/i18n/ko/nav.js +18 -0
  48. package/dist/public/i18n/ko/profile.js +201 -0
  49. package/dist/public/i18n/ko/settings.js +127 -0
  50. package/dist/public/i18n/ko/status.js +23 -0
  51. package/dist/public/i18n/ko.js +18 -0
  52. package/dist/public/i18n.js +55 -0
  53. package/dist/public/index.html +1 -0
  54. package/dist/public/lib.js +25 -17
  55. package/dist/public/pages/applications.js +17 -21
  56. package/dist/public/pages/documents.js +153 -107
  57. package/dist/public/pages/home.js +88 -53
  58. package/dist/public/pages/interview.js +105 -51
  59. package/dist/public/pages/jobs.js +330 -106
  60. package/dist/public/pages/profile.js +157 -152
  61. package/dist/public/pages/settings.js +270 -72
  62. package/dist/public/styles.css +38 -3
  63. package/dist/web.mjs +859 -103
  64. package/docs/FAQ.md +26 -5
  65. package/docs/MCP_TOOLS.md +26 -12
  66. package/docs/START_WORKFLOW.md +2 -2
  67. package/docs/SUPPORTED_AI_APPS.md +13 -0
  68. package/docs/WORKFLOWS.md +80 -23
  69. package/docs/career-os/README.md +8 -3
  70. package/docs/career-os/eop/career-description.md +136 -0
  71. package/docs/career-os/eop/cover-letter.md +7 -5
  72. package/docs/career-os/eop/fit-analysis.md +15 -5
  73. package/docs/career-os/eop/interview-prep.md +209 -21
  74. package/docs/career-os/eop/job-analysis.md +5 -3
  75. package/docs/career-os/eop/profile-extraction.md +5 -4
  76. package/docs/career-os/knowledge/cover-letter.md +4 -4
  77. package/docs/career-os/knowledge/fit-matching.md +8 -8
  78. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  79. package/docs/career-os/knowledge/interview-technical.md +106 -4
  80. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  81. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  82. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  83. package/docs/career-os/knowledge/portfolio.md +3 -3
  84. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  85. package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
  86. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  87. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  88. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  89. package/package.json +2 -2
  90. package/packages/core/src/onboarding.ts +2 -1
  91. package/packages/core/src/services.ts +138 -21
  92. package/packages/core/src/summary.ts +59 -1
  93. package/packages/core/src/verify/lint.ts +4 -0
  94. package/packages/db/src/index.ts +1 -0
  95. package/packages/db/src/repositories.ts +0 -0
  96. package/packages/db/src/schema.ts +17 -0
  97. package/packages/exporters/src/index.ts +28 -17
  98. package/packages/knowledge/src/index.ts +46 -7
  99. package/packages/mcp-tools/src/bridge.ts +194 -13
  100. package/packages/mcp-tools/src/tools.ts +390 -38
  101. package/packages/mcp-tools/src/update.ts +31 -16
  102. package/packages/parsers/src/documents.ts +12 -1
  103. package/packages/prompts/src/humanize.ts +46 -0
  104. package/packages/prompts/src/index.ts +5 -1
  105. package/packages/prompts/src/install.ts +4 -4
  106. package/packages/prompts/src/onboarding.ts +4 -4
  107. package/packages/prompts/src/system.ts +17 -8
  108. package/packages/shared/src/enums.ts +14 -4
  109. package/packages/shared/src/schemas.ts +41 -0
  110. package/packages/workflows/src/definitions.ts +47 -10
  111. package/scripts/init.ts +284 -13
  112. package/dist/site/analytics.js +0 -97
  113. package/dist/site/favicon.svg +0 -1
  114. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  115. package/dist/site/icon-180.png +0 -0
  116. package/dist/site/icon-512.png +0 -0
  117. package/dist/site/index.html +0 -565
  118. package/dist/site/landing.js +0 -54
  119. package/dist/site/llms-install.txt +0 -218
  120. package/dist/site/robots.txt +0 -4
  121. package/dist/site/sitemap.xml +0 -21
  122. package/dist/site/start.html +0 -353
  123. package/dist/site/style.css +0 -559
  124. package/dist/site/vercel.json +0 -20
@@ -41,7 +41,7 @@
41
41
  - 팔로업 간격: 1차 4-5일, 마지막 가치-부가 "break-up", 그 뒤 정지. 상대가 답하면 카운터 리셋, "거절"이면 케이던스 영구 정지.
42
42
  - 정보성 대화 끝에 1-2개의 **질 좋은** 추가 소개와 진척 공유 허락을 청한다.
43
43
  - 네트워크가 없으면 콜드/커뮤니티 경로로 분기하고, 한국 채널(커피챗·학과 선배·오픈채팅·블라인드)을 제시한다.
44
- - 저장(연락처·노트·아웃리치 초안) 후 대시보드 링크(http://127.0.0.1:4319)를 보여주고 각 단계를 쉬운 한국어로 설명한다.
44
+ - 저장(연락처·노트·아웃리치 초안) 후 대시보드 링크(http://127.0.0.1:4319)를 보여주고 각 단계를 사용자의 언어로 쉽게 설명한다.
45
45
 
46
46
  ### Don't
47
47
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 1. 무엇을 하는가
6
6
 
7
- 이 도메인은 지원자 여정의 **끝단**, 즉 오퍼가 나온 뒤부터 입사 정착·탈락 환류까지의 **"결정"** 영역이다. 인접한 salary-negotiation은 "어떻게 더 받아낼 것인가(전술·앵커링)"이고, 이 도메인은 그 결과로 손에 쥔 오퍼들을 두고 **"어느 것을 택할까 / 거절을 어떻게 통보할까 / 카운터오퍼를 받을까 / 수락 후 번복 비용은 / 첫 90일을 어떻게 설계할까 / 떨어진 이유를 어떻게 진단할까"** 를 결정하는 것이다. 따라서 리크루터 스크린의 보상 질문 응답·범위 앵커링 같은 **협상 전술은 salary-negotiation 도메인으로 위임**하고, 여기서는 **이미 받은 복수 오퍼의 비교·결정**에 집중한다. 핵심 원리: 결정은 직관이 아니라 **가중 비교 매트릭스 + 명시적 결정 임계값**으로 내리고(인간은 미래 효용을 체계적으로 오예측한다), total comp은 명목 합이 아니라 **세후·다년 현가**로 비교하며(한국은 RSU가 아니라 퇴직금·포괄임금·비상장 스톡옵션 세제가 진짜 함정), 수락 후 번복은 **법적 위험이 아니라 평판 비용**으로(단 한국 채용내정 법리는 별도) 다룬다.
7
+ 이 도메인은 지원자 여정의 **끝단**, 즉 오퍼가 나온 뒤부터 입사 정착·탈락 환류까지의 **"결정"** 영역이다. 인접한 salary-negotiation은 "어떻게 더 받아낼 것인가(전술·앵커링)"이고, 이 도메인은 그 결과로 손에 쥔 오퍼들을 두고 **"어느 것을 택할까 / 거절을 어떻게 통보할까 / 카운터오퍼를 받을까 / 수락 후 번복 비용은"** 를 결정하는 것이다(수락 직전 결정까지가 이 도메인 소유 — **오퍼 결정 후 탈락 진단은 정본 `rejection-triage-iteration`(§2 8-stage), 입사 정착 30-60-90은 정본 `onboarding-first-90-days`에 위임**하고 여기서 재정의하지 않는다). 따라서 리크루터 스크린의 보상 질문 응답·범위 앵커링 같은 **협상 전술은 salary-negotiation 도메인으로 위임**하고, 여기서는 **이미 받은 복수 오퍼의 비교·결정**에 집중한다. 핵심 원리: 결정은 직관이 아니라 **가중 비교 매트릭스 + 명시적 결정 임계값**으로 내리고(인간은 미래 효용을 체계적으로 오예측한다), total comp은 명목 합이 아니라 **세후·다년 현가**로 비교하며(한국은 RSU가 아니라 퇴직금·포괄임금·비상장 스톡옵션 세제가 진짜 함정), 수락 후 번복은 **법적 위험이 아니라 평판 비용**으로(단 한국 채용내정 법리는 별도) 다룬다.
8
8
 
9
9
  ## 2. 플레이북
10
10
 
@@ -18,10 +18,10 @@
18
18
  - **(stock 분해는 한국 오퍼 유형에 맞춰 적용한다)** "연환산·cliff·refresh" RSU 모델은 미국 빅테크 모델이다. 한국은 **상장 대기업=주식 거의 없음(현금·퇴직금 위주), 비상장 스타트업=스톡옵션(주식매수선택권)·4년 베스팅이 표준이 아닐 수 있음, 중견=현금 위주**다. 비상장 스톡옵션은 행사가·희석·청산우선권으로 기대가치를 할인하고, **행사이익이 근로소득으로 과세되나 벤처기업법 과세특례(연 한도 내 비과세·분할납부)**가 있는지 확인한다. *(과세특례 한도·요건은 현행법 기준이며 개정·사안별로 달라질 수 있음 — 실제 적용 전 최신 세법·세무사 확인 권장)* stock이 없는 오퍼에는 stock 행을 비우고 현금·퇴직금·성장으로 비교한다(빈 stock을 가짜로 통과시키지 말 것). _(출처: levels.fyi(연환산) / 벤처기업법 스톡옵션 과세특례, 벤더/한국 1차)_
19
19
  - **(현직 카운터오퍼는 "근본 원인 진단"으로 결정한다 — 기본값 고정 아님)** 카운터오퍼를 받으면 먼저 **"왜 떠나려 했는가"(매니저·성장 정체·번아웃·저평가 연봉)** 를 적고, 그 원인이 돈으로 해결되는지 검증한다. 원인이 **순수 저평가 연봉**이고 카운터가 그것을 시장가로 맞춘다면 수락이 합리적일 수 있다(정답은 "depends"다 — 한쪽으로 못박지 말 것). 원인이 비보상 요인이면 돈은 고치지 못한다. 사용자에게 설명할 때 **"카운터오퍼 수락자 80%가 6개월 내 퇴사" 류 수치는 출처 추적이 거의 불가능한 도시전설(존재 불명 기관 인용)이므로 "통계"가 아니라 "근본 원인 논리"로 설득**한다. _(출처: LHH/Progressive(일화적·출처 검증 불가, 벤더) — 비판적 인용)_
20
20
  - **(수락 후 번복은 평판 비용으로, 한국은 채용내정 법리도 본다)** 미국 맥락에서 reneging은 소송보다 **리크루터·추천인·해당 회사 네트워크의 신뢰 상실**이 진짜 비용이다(업계 전체 블랙리스트는 실재하지 않으나 개별 리크루터의 기억은 실재). 다만 **한국은 비대칭**이다: 근로계약서 체결 전 오퍼 단계에서는 지원자 번복의 법적 위험이 낮지만, **회사가 "채용내정"을 일방 취소하면 판례상 근로계약 성립으로 보아 손해배상 다툼 여지**가 있다(at-will 전제의 미국 가이드를 한국에 그대로 적용하지 말 것). *(채용내정 성립 여부·손해배상 범위는 사안별 판례 기준이며 개정·상황별로 달라질 수 있음 — 실제 분쟁 시 노무사·변호사 확인 권장)* 불가피한 번복은 최대한 빨리·사과와 함께·짧은 사유·다리를 태우지 않는 톤으로 통보한다. _(출처: MIT Sloan CDO + CU Boulder(평판 비용, 1차/2차) / 한국 채용내정 법리, 한국 판례)_
21
- - **(입사 전 30-60-90일 정착 계획을 초안한다)** 수락이 정해지면 입사 첫날 전에 0~30일=흡수·학습(기여 아님), 31~60일=초기 성과(early win)·동맹 구축, 61~90일=손익분기 계획을 초안하고, **90일 시점의 "성공 정의"를 매니저와 입사 전/직후 합의**한다. 단 한국에서는 90일 이전에 **전직 통보기간(보통 1개월)·인수인계 의무·경업금지(non-compete) 조항**이 입사일 협상의 제약이므로 함께 점검한다. _(출처: Michael Watkins, The First 90 Days, 1차 / 한국 전직 실무)_
21
+ - **(수락이 정해지면 30-60-90 정착 계획은 정본 도메인으로 위임한다)** 30-60-90 정착 계획(0-30/31-60/61-90 마일스톤·매니저 성공정의·한국 전직 통보기간/경업금지)은 **정본 `onboarding-first-90-days`**를 따른다. 이 도메인이 소유하는 것은 **'수락 직전 준비 결정'**(입사일·통보기간이 오퍼 협상에 거는 제약 점검)까지이고 **'입사 후 실행'은 onboarding 소유**다 — 30-60-90을 여기서 재작성하지 않고 수락 확정(`final_passed`) 시점에 정본으로 연결만 한다. _(정본: `knowledge/onboarding-first-90-days.md`)_
22
22
  - **(한국 오퍼레터는 "최종 확정"이 아니라 "처우 조율 단계"로 다루되 구속력은 사안별)** 오퍼레터는 근로계약서와 달리 구속력이 약한 경우가 많아 직급(호봉)·입사일·부서/직무 배치·사이닝 등 **비-연봉 레버**로 협상할 여지가 있다. 단 "구속력 약함"을 단정하지 말고 **"구속력은 사안별이며 채용내정 성립 시 달라진다"** 로 약화한다. **포괄임금제 여부를 확인**해 명목연봉끼리 단순 비교하지 않는다(같은 명목연봉도 실질 시급이 다르다). _(출처: 리멤버 채용 인사이트/잡코리아(2차) + 채용내정 법리(한국 판례))_
23
23
  - **(직군별 오퍼 구조 차이를 반영한다)** 백엔드/SRE는 **온콜 수당**, ML·리서치 사이언티스트는 **equity 비중·연구 자율성**, 프론트/PM은 base·성장 경로가 더 무겁고, **신입 vs 시니어 sign-on 관행**도 다르다. 모든 직군에 같은 매트릭스 가중치를 기계적으로 들이대지 말고 직군에 맞춰 축·가중치를 조정한다. _(출처: 직군별 보상 구조 실무 — 보조)_
24
- - **(탈락은 "환류 루프"로 처리한다)** 탈락 통보를 받으면 (1) 정중한 1회 한정 피드백 요청, (2) 단계별(서류/코딩/1차/최종/처우) 막힌 지점 기록·패턴화, (3) 다음 액션 1~2개로 환원한다. 면접까지 자주 가는데 최종에서 떨어지면 적합도/마무리 문제, 서류에서 막히면 포지셔닝 문제로 진단이 갈린다. 거절을 관계 종료가 아니라 미래 파이프라인(재오픈 포지션 추천 경로)으로 처리한다. _(출처: 대학 커리어센터(UW, CU Boulder), 2차)_
24
+ - **(탈락은 정본 도메인으로 위임한다)** 탈락 통보 수신 후의 단계 진단·재지원 타이밍·피드백 요청·반복 패턴 감지는 **정본 `rejection-triage-iteration`(§2 8-stage enum: `pre_screen|recruiter_screen|aptitude_test|assignment|interview|final_fit|ghosted_pre|ghosted_post`)**을 따른다. 이 도메인은 탈락 진단을 자체 단계 enum으로 재정의하지 않고, `update_application_status(rejected)` 시점에서 그 정본으로 연결만 한다. _(정본: `knowledge/rejection-triage-iteration.md`)_
25
25
 
26
26
  ### Do
27
27
 
@@ -33,8 +33,8 @@
33
33
  - 복수 오퍼 만료일을 한 시점으로 정렬하고, 짧은 마감(exploding)에는 **통상 3~5영업일**(진행 중 최종단계가 있으면 그 일정까지)을 **단 한 번 구체 날짜로** 요청한다.
34
34
  - 수락/거절 통보는 결정 후 24~48시간 내, (감사 + 결정 + 비방 없는 사유 + 관계 유지) 4요소 스크립트로 보낸다.
35
35
  - 현직 카운터오퍼는 "왜 떠나려 했는가"를 먼저 적고 그 원인이 보상으로 해결되는지 검증한다(저평가 연봉이면 수락도 합리적 — depends).
36
- - 수락 확정 시 30-60-90일 계획 초안과 "90일 성공 정의"를 매니저와 합의하고, 한국이면 전직 통보기간·인수인계·경업금지를 입사일 협상에 반영한다.
37
- - 탈락 시 단계(서류/면접/최종/처우)별 막힌 지점을 기록·진단하고 정중한 1회 피드백 요청과 관계 유지를 시도한다.
36
+ - 수락 확정 시 30-60-90 정착 계획은 정본 `onboarding-first-90-days`로 연결한다(이 도메인에서 재작성하지 않는다). 한국 전직 통보기간·인수인계·경업금지 중 **입사일 협상에 거는 제약**만 여기서 점검한다.
37
+ - 탈락 시 단계 진단·재지원·피드백 요청은 정본 `rejection-triage-iteration`(8-stage)로 연결한다(이 도메인에서 단계 enum을 재정의하지 않는다).
38
38
  - 한국: 연봉 여지가 좁으면 직급·입사일·부서배치·사이닝으로 협상하고, 포괄임금제·퇴직금 포함여부를 확인한다.
39
39
  - "80% 퇴사" 류 수치를 쓸 때는 일화적이고 출처 검증 불가임을 함께 밝힌다(과신 금지).
40
40
 
@@ -52,7 +52,7 @@
52
52
  - 리크루터 스크린의 보상 질문 응답·범위 앵커링을 이 도메인에서 다루지 않는다(salary-negotiation으로 위임).
53
53
  - 한국 오퍼레터를 근로계약서와 동일한 최종 확정으로 오해하거나, 반대로 "구속력 무조건 약함"으로 단정하지 않는다.
54
54
  - 포괄임금 연봉과 비-포괄 연봉을 같은 명목값으로 동일 취급하지 않는다.
55
- - 탈락을 관계 종료로 처리해 환류·재지원 경로를 닫지 않는다.
55
+ - 탈락 진단·재지원을 이 도메인에서 단계 enum으로 재정의하지 않는다(정본 `rejection-triage-iteration` 8-stage 사용). 탈락을 관계 종료로 처리해 재지원 경로를 닫지도 않는다.
56
56
  - "매니저=가장 강한 잔류 예측 변수" 같은 단정을 출처 없이 사용자에게 사실로 제시하지 않는다.
57
57
 
58
58
  ### 워크드 예시 (Before → After)
@@ -83,8 +83,8 @@
83
83
  | R6 | decline_script_4elements — 거절/번복 통보가 4요소를 갖췄는가 | (감사 + 결정 + 비방 없는 사유 + 관계 유지) 존재 AND 시점(24~48h) 언급 | **[det 부분]** 시점 토큰 {24시간, 48시간, 24~48} ≥1, 비방어휘 부재(블랙리스트 욕설·비난어 == 0). **[ai/H]** "감사·관계유지·비방 없는 사유" 3요소의 진위는 의미 판단 → ai | critical |
84
84
  | R7 | counteroffer_root_cause — 카운터오퍼 시 근본 원인 진단이 있고 기본값을 한쪽으로 못박지 않는가 | 근본 원인 진단 단계 존재 AND "depends/원인별 분기" 표현 존재 AND ('80%' 류 통계 등장 시에만 '일화적/출처 검증 불가' 동반) | **[det 부분]** {왜 떠나려, 근본 원인, 이직 동기, 떠나려던 이유} ≥1 + {depends, 경우에 따라, 저평가면 수락, 원인이 보상} ≥1. **안티패턴 0건 조건:** `'80%'|'80퍼'|'대부분 퇴사'` 등장 횟수 == 0 **이거나**, 등장 시 같은 30토큰 내 `일화적|anecdotal|출처 검증 불가|도시전설` 동반. **[ai/H]** "진단이 실제로 보상해결성을 검증했는지"는 의미 판단 | critical |
85
85
  | R8 | reneging_reputation_kr_law — 번복을 평판 비용으로 프레이밍하되 한국 채용내정 비대칭을 다루는가 | 평판 비용 키워드 존재 AND 소송 위험 과장 부재 AND (locale=KR이면 채용내정/회사 측 취소 언급) | **[det 부분]** {평판, 리크루터, 추천인, 네트워크} ≥1 + {소송/형사처벌 단정 표현} == 0. **[ai/H]** locale=KR일 때 "채용내정 법리·회사 측 일방취소 비대칭"을 정확히 다뤘는지는 의미 판단 → ai (보조: {채용내정, 채용 내정, 회사가 취소} ≥1) | critical |
86
- | R9 | onboarding_30_60_90 — 30-60-90 계획과 매니저 성공정의가 있는가 | 3개 구간(0-30/31-60/61-90) 모두 존재 AND 각 구간 목표 ≥1 AND 한국이면 전직 통보기간/경업금지 언급 | **[det]** 구간 마커 {0-30·31-60·61-90 또는 30/60/90} 3개 + 각 구간 bullet ≥1 카운트 + (locale=KR이면 {통보기간, 인수인계, 경업금지} ≥1) | advisory |
87
- | R10 | rejection_diagnosis_loop — 탈락 처리에 단계별 진단과 환원 액션이 있는가 | 탈락 단계 분류 존재 AND 다음 액션 ≥1 | **[det]** 단계 라벨 {서류, 코딩, 1차, 최종, 처우} ≥2 distinct + {다음 액션, next action, 액션} 항목 ≥1 카운트 | advisory |
86
+ | R9 | onboarding_delegated — 30-60-90 정착을 정본으로 위임했는가(자체 재작성 금지) | 수락 확정 시 `onboarding-first-90-days` 참조 존재; 30-60-90 초안이 있으면 세부 구조·마일스톤은 정본(onboarding §3·§5 R9)이 소유 | **[det]** {onboarding-first-90-days, 30-60-90 정본, 정착 계획 도메인} 참조 ≥1. 구간 마커가 있으면 존재로 카운트하되 세부 목표 검증은 onboarding 정본 R9에 위임(정본 stage 정의 변경 시 본 라인도 동기화) | advisory |
87
+ | R10 | rejection_delegated — 탈락 진단을 정본 도메인으로 위임했는가(자체 stage enum 재정의 금지) | `rejection-triage-iteration` 참조 존재 AND 본 문서가 자체 stage 라벨 집합을 재정의하지 않음 | **[det]** `rejection-triage-iteration.md §2`(8-stage enum) 참조 토큰 ≥1, 그리고 경쟁 stage enum({서류·코딩·1차·최종·처우}) 동시정의 부재. 단계 분류 루브릭 소유=정본 rejection-triage §2 R1·R2(정본 stage 정의 변경 시 본 라인도 동기화) | advisory |
88
88
  | R11 | korea_locale_lever — 한국 맥락이면 비-연봉 레버·함정이 반영됐는가 | locale=KR일 때 한국 특화 항목 ≥2 distinct | **[det 부분]** {오퍼레터, 포괄임금, 처우표/연봉등급, 호봉, 입사일, 부서배치, 사이닝, 퇴직금} distinct ≥2. **[ai/H]** "단어 나열이 아니라 실제 레버로 작동하는지"는 의미 판단 | advisory |
89
89
  | R12 | evidence_grounding — 권고에 명명된 출처가 매핑되고 일화적 수치가 표시됐는가 | 출처 토큰 수 ≥ 핵심 권고 수와 같은 규모 AND 일화적 수치에 단서 부착 | **[det 부분]** 출처 토큰 {PON, levels.fyi, First 90 Days, Watkins, MIT Sloan, LHH} 개수가 0 초과이고 핵심 권고 블록 수에 비해 결손 아님(`sources_count`와 권고 수 비교). **[ai/H]** "어느 출처가 어느 권고를 실제로 근거하는지"의 매핑 정확성은 의미 판단 → ai | advisory |
90
90
 
@@ -116,6 +116,6 @@
116
116
  - **신선도/정합성 검사(LLM 없이 규칙):** 벤치마크의 `captured_at`이 6개월 초과면 "stale", 오퍼 통화·지역과 벤치마크 불일치면 "mismatch", `locale=KR`인데 stock 모델이 RSU로 채워졌으면 "rsu_locale_mismatch" 플래그를 함께 반환.
117
117
  - **데이터 형태 힌트:**
118
118
  - `offer_decision = { application_id, deadline(ISO date), total_comp:{ base, stock:{ grant, vest_years, annualized, cliff_note, is_kr_stock_option:bool, tax_special_treatment:bool }, bonus, sign_on, after_tax_estimate, retirement_in_base:bool, multiyear_raise_pct }, comp_is_inclusive_wage:bool(KR), weighted_matrix:[{ axis, weight:int, score_per_offer{} }], weights_sum:int, batna_column:{ type:'current_job'|'other_process'|'no_offer', scored:bool }, decision_threshold_pct:int, tiebreaker_order:[str], decision:'accept'|'decline'|'counter'|'pending', rationale, scripts:{ decline, accept, extend_request, counteroffer_response } }`
119
- - `onboarding_plan = { application_id, phases:[{ window:'0-30'|'31-60'|'61-90', goals:[], success_definition_agreed:bool }], kr_constraints:{ notice_period_weeks:int, non_compete:bool, handover_note } }`
120
- - `rejection_diagnosis = { application_id, stage:'screen'|'document'|'coding'|'interview'|'final'|'comp', pattern_note, next_actions:[] }`
119
+ - **(Phase B 힌트 — 런타임 미serve)** 30-60-90 온보딩 데이터 모델(`plan{...}`)의 **정본은 `onboarding-first-90-days` §5**다. 이 도메인은 그 모델을 재정의하지 않고 `final_passed` 전이 시 연결만 한다.
120
+ - **(Phase B 힌트 — 런타임 미serve)** 탈락 진단 데이터 모델(`rejection_reviews`, stage enum)의 **정본은 `rejection-triage-iteration` §5의 8-stage**다. 이 도메인은 그 모델을 재정의하지 않고 `update_application_status(rejected)` 시점에서 연결만 한다.
121
121
  - 루브릭은 LLM 없이 셀 수 있게 평탄화: `axes_count:int`, `weights_sum:int`, `batna_column_scored:bool`, `decision_threshold_present:bool`, `comp_components:[enum]`, `has_after_tax:bool`, `is_kr_stock_option:bool`, `decline_elements:[enum]`, `anecdotal_stat_present:bool`, `anecdotal_caveat_present:bool`, `kr_renege_law_present:bool`, `phases_count:int`, `rejection_stages:[enum]`, `sources_count:int`, `check_weight:'critical'|'advisory'`, `check_grade:'det'|'ai'`. **`check_grade='ai'` 필드는 외부 AI 판정 결과를 받아 저장**(CareerMate는 det 필드만 자동 카운트).
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 1. 무엇을 하는가
6
6
 
7
- 포트폴리오는 "예쁘게 보여주기"가 아니라 **채용 의사결정자가 짧은 시간에 지원자의 문제 해결 능력을 신뢰하게 만드는 증거 모음**이다. 핵심은 다섯 축이다: (1) `problem → approach → impact` 3막 서사, (2) 직무에 맞는 3~6개 선별(큐레이션), (3) 팀 작업에서 "내가 실제로 한 것"을 1인칭으로 분리, (4) 정량 결과(숫자·전후 비교)로 임팩트 입증, (5) 직군별 규범(eng/design/PM/data) 준수. CareerMate는 LLM이 없으므로, 작성은 연결된 외부 AI가 하고 CareerMate는 그 결과를 **구조화 레코드(`PortfolioItem`)로 저장**한 뒤 **LLM 없이 결정적으로 셀 수 있는 lint 루브릭**으로 검증한다. 따라서 아래 루브릭은 자유 텍스트가 아니라 **구조화 필드를 입력 전제**로 설계되어 있다.
7
+ 포트폴리오는 "예쁘게 보여주기"가 아니라 **채용 의사결정자가 짧은 시간에 지원자의 문제 해결 능력을 신뢰하게 만드는 증거 모음**이다. 핵심은 다섯 축이다: (1) `problem → approach → impact` 3막 서사, (2) 직무에 맞는 3~6개 선별(큐레이션), (3) 팀 작업에서 "내가 실제로 한 것"을 1인칭으로 분리, (4) 정량 결과(숫자·전후 비교)로 임팩트 입증, (5) 직군별 규범(eng/design/PM/data) 준수. CareerMate는 LLM이 없으므로, 작성은 연결된 외부 AI가 한다. 결과를 **구조화 레코드(`PortfolioItem`)로 저장**하고 **LLM 없이 결정적으로 셀 수 있는 lint 루브릭**으로 검증하는 `save_portfolio_item`·`lint_portfolio`는 **Phase B 설계이며 현재 미구현** — 지금은 연결 AI가 아래 루브릭을 **수동(advisory)**으로 적용한다(§5 Phase B 힌트; 자유텍스트 입력 시 구조화 필드 전제인 R1·R3·R4 등은 측정이 degrade되므로 연결 AI가 해석 적용). 따라서 아래 루브릭은 자유 텍스트가 아니라 **구조화 필드를 입력 전제**로 설계되어 있다.
8
8
 
9
9
  ## 2. 플레이북
10
10
 
@@ -16,7 +16,7 @@
16
16
  - **임팩트는 가능한 한 숫자 + 단위 + 전후(before→after) 비교로 표현한다(예: 전환율 3.2%→4.7%, +47%, 처리시간 -40%).** 숫자가 없으면 비교 기준·규모·범위(사용자 수·기간·트래픽)라도 제시한다. **이 수치는 반드시 사용자가 제공한 실측이어야 하며, AI가 그럴듯하게 지어내선 안 된다.** _(출처: The Muse STAR Method)_
17
17
  - **직군별 규범을 따른다.** eng = 실행 가능한 README(목적·문제·실행법·라이브 데모/스크린샷) + 깔끔한 커밋 + 최소 1개 end-to-end 프로젝트; design = 리서치→결정→반복의 why; PM = 북극성/가드레일/진단 지표로 가치 정의; data = train/test 분리·복수 평가지표·온라인 A/B·가드레일·비즈니스 임팩트 정량화. _(출처: Flatiron README best practices / Meta Analytics DS framework)_
18
18
  - **작업 당시의 제약(constraints)을 한 줄이라도 명시한다** — 마감, 기존 브랜드/legacy 시스템, 기술적 제약, 이해관계자/NDA. 제약 속 결정을 보여줘 맥락 인지를 입증한다. _(출처: icreatives Portfolio Review)_
19
- - **한국 채용 맥락의 '경력기술서'는 포트폴리오 역할을 하되 형식이 다르다.** 회사·기간·역할·성과를 표/불릿로 쓰는 경우가 많아 산문 3막을 강제하면 정상 경력기술서를 오탐한다. 따라서 3막 검증은 **`item_type`이 `case_study`인 항목에만** 적용하고, `경력기술서` 유형은 필드(역할·성과·기간) non-empty로만 검증한다. 사실·수치·고유명사는 **절대 과장·날조하지 않는다**(AGENTS.md 프라임 디렉티브). _(출처: AGENTS.md 사실 보존 규칙)_
19
+ - **한국 채용 맥락의 '경력기술서'는 포트폴리오 역할을 하되 형식이 다르다.** 경력기술서 **작성 규범·루브릭의 정본은 `eop/career-description.md`(+ `resume.md`)**이며, 이 포트폴리오 도메인은 그 규범을 재정의하지 않고 `case_study`가 아닌 `경력기술서` 항목을 그 정본으로 위임한다(**`case_study`의 3막 구성·증거 링크 규범은 이 portfolio.md 소유**). 회사·기간·역할·성과를 표/불릿로 쓰는 경우가 많아 산문 3막을 강제하면 정상 경력기술서를 오탐하므로, 3막 검증은 **`item_type`이 `case_study`인 항목에만** 적용하고, `경력기술서` 유형은 필드(역할·성과·기간) non-empty로만 검증한다. 사실·수치·고유명사는 **절대 과장·날조하지 않는다**(AGENTS.md 프라임 디렉티브). _(출처: AGENTS.md 사실 보존 규칙)_
20
20
 
21
21
  ### Do
22
22
 
@@ -57,7 +57,7 @@
57
57
 
58
58
  ## 3. 검증 루브릭 (연결된 AI가 산출물을 이 기준으로 자가검증)
59
59
 
60
- > **전제(중요):** 모든 루브릭은 자유 텍스트가 아니라 **`PortfolioItem` 구조화 레코드 배열**을 입력으로 받는다. "항목(item) 경계"는 **레코드 1개 = 1 항목**으로 못박는다. 자유 텍스트는 루브릭 대상이 아니며, 먼저 `save_portfolio_item`으로 구조화해야 한다. 이로써 대부분 체크가 키워드 매칭 대신 **필드 non-empty / 패턴 카운트**로 결정적(deterministic)·LLM-프리가 된다.
60
+ > **전제(중요):** 모든 루브릭은 자유 텍스트가 아니라 **`PortfolioItem` 구조화 레코드 배열**을 입력으로 받는다. "항목(item) 경계"는 **레코드 1개 = 1 항목**으로 못박는다. 자유 텍스트는 루브릭 대상이 아니며, 먼저 `save_portfolio_item`으로 구조화해야 한다. 이로써 대부분 체크가 키워드 매칭 대신 **필드 non-empty / 패턴 카운트**로 결정적(deterministic)·LLM-프리가 된다. **단 `save_portfolio_item`·`lint_portfolio`는 Phase B 미구현 — Phase A 현재는 연결 AI가 이 구조를 적용해 루브릭을 수동(advisory)으로 점검한다(자유텍스트 입력 시 결정성 degrade).**
61
61
 
62
62
  | ID | 검사 항목 | 합격 기준 | 측정 방법(LLM 없이 셀 수 있게) |
63
63
  |----|-----------|-----------|--------------------------------|
@@ -10,20 +10,22 @@
10
10
  1. **파싱 안전성(parse-safety)** — ATS가 텍스트를 깨뜨리지 않고 구조를 인식할 수 있는가(표준 헤더·단일 컬럼·연락처 위치·날짜 형식·폰트/아이콘·파일형식).
11
11
  2. **JD 키워드 커버리지(keyword coverage)** — JD에서 반복·강조된 하드스킬/직무명/필수요건 용어가 산출물 본문에 실제로 존재하는가, 과도한 키워드 스터핑 없이.
12
12
 
13
- 핵심 설계 원칙: 모든 항목은 **LLM 없이도 셀 수 있는 형태(개수·존재여부·비율·정규식 패턴)**로 환원해 두어, 연결된 AI가 기계적으로 카운트해 합격/불합격을 판정하고 루프를 돌 수 있게 한다. 이력서·LinkedIn·자소서(텍스트 본문 기준)에 공통 적용한다.
13
+ 핵심 설계 원칙: 모든 항목은 **LLM 없이도 셀 수 있는 형태(개수·존재여부·비율·정규식 패턴)**로 환원해 두어, 연결된 AI가 결정론적으로 카운트해 판정하고 루프를 돌 수 있게 한다. 단 **코어 파스세이프티(C1~C7·C12)만 합격 게이트**이고 **키워드 커버리지 축(C8~C11)은 advisory**(미달=갭 보고, PASS 비차단)다. 이력서·LinkedIn·자소서(텍스트 본문 기준)에 공통 적용한다.
14
14
 
15
15
  ## 2. 검사 항목
16
16
 
17
+ > **게이트 구분:** C1~C7·C12 = 코어 파스세이프티(합격 게이트). C8~C11 = 키워드 커버리지 축(**advisory** — 미달 시 갭을 검출·보고하되 PASS를 막지 않으며, 최종 PASS 판정은 연결 AI가 JD·문맥·모델 능력에 따라 내린다).
18
+
17
19
  | ID | 검사 | 합격 기준 | 측정 방법(셀 수 있게) |
18
20
  |----|------|-----------|------------------------|
19
21
  | C1 | 표준 섹션 헤더 존재 | 핵심 섹션이 표준 라벨로 존재 (이력서: Experience/Work Experience/Professional Experience, Education, Skills 중 최소 2개 일치; LinkedIn: About/Experience/Skills) | 산출물에서 헤더 라인을 추출해 표준 라벨 화이트리스트와 정확/근접 일치(대소문자 무시)한 **개수 ≥ 2**. "My Journey"·"내 이야기"·"The Toolkit" 같은 창의적 헤더는 0으로 카운트 |
20
22
  | C2 | 비표준/창의적 헤더 부재 | 표준 화이트리스트에 없는 섹션 헤더 = 0개 | 헤더 라인 중 화이트리스트 밖 라벨 개수. **0이면 합격** |
21
- | C3 | 단일 컬럼(다단 금지) | 다단 레이아웃 신호 없음 | 한 줄에 큰 공백(탭/2칸 이상 스페이스 ≥3개) 또는 좌우 분리 패턴이 있는 라인 비율 **< 10%**. 마크다운/플레인텍스트 추출본에서 같은 줄에 두 개의 독립 항목이 나란히 오는 라인 = 0 목표 |
23
+ | C3 | 단일 컬럼(다단 금지) | 다단 레이아웃 신호 없음 | **`.docx` 원본만 신뢰**(C4와 동일하게 `w:tbl`·`w:txbxContent` 카운트 == 0을 **코어 게이트로 유지** — 다단 `.docx`는 PASS 불가). **PDF·추출된 plain/markdown 텍스트만으로는 컬럼 검출 신뢰 불가** → 원본 좌표 분석이 없으면 '검출 불가, 사용자 경고'로 강등한다(추출이 다단을 단일 스트림으로 reflow). 공백-갭 휴리스틱(탭/2칸+ ≥3개)은 약한 보조 신호로만 쓰고 거짓양성(우측 정렬 날짜줄)을 고지한다 |
22
24
  | C4 | 표·텍스트박스 부재 | 표 구조 0개 | 마크다운 표 구분자(`\|---\|`)·HTML `<table>`·텍스트박스 마커 출현 횟수 **= 0** |
23
25
  | C5 | 이미지·아이콘·스킬바 부재 | 그래픽 요소 0개 | 이미지 마크업(`![]`, `<img`)·이모지/심볼로 표기된 연락처 아이콘·"skill bar" 채움문자(예: ●●●○○, █) 출현 횟수 **= 0** |
24
26
  | C6 | 연락처가 본문 상단에 위치 | 이메일·전화가 헤더/푸터가 아닌 본문 첫 영역에 존재 | 본문 텍스트 첫 15% 구간(또는 첫 5줄) 안에서 이메일 정규식(`[\w.+-]+@[\w-]+\.[\w.]+`) 매치 **≥ 1** 그리고 전화 패턴 매치 **≥ 1** |
25
27
  | C7 | 일관된 날짜 형식 | 경력/학력 기간이 ATS가 인식하는 형식으로 통일 | 날짜 토큰을 추출해 허용 패턴(`MMM YYYY`, `Month YYYY`, `MM/YYYY`, `YYYY.MM`, `YYYY-MM`) 중 **단일 패턴 비율 ≥ 90%**. 혼용·"현재/Present" 외 자유서술 날짜 비율 < 10% |
26
- | C8 | JD 하드스킬 키워드 커버리지 | JD 상위 키워드의 충분 비율을 본문이 포함 | JD에서 추출한 상위 하드스킬/도구/자격 키워드 집합 N개 대비, 산출물 본문에 (어형 정규화 후) 존재하는 개수 M → **M/N ≥ 0.75** (Jobscan 권장 75%+, 스위트스폿 80%). JD 미제공 시 본 항목은 N/A 처리하고 사유 기록 |
28
+ | C8 *(advisory)* | JD 하드스킬 키워드 커버리지 | JD 상위 키워드의 충분 비율을 본문이 포함 | 산출물 본문에 존재하는 키워드 개수 M / JD 추출 키워드 N → **M/N ≥ 0.70 = 권장 출발점(advisory·비차단; 0.70은 법칙이 아니라 출발점이며 AI가 JD 규모·역할 수에 맞게 조정)**. 바로 옆 75%+(Jobscan 권장)/80%(스위트스폿)는 **비구속 상류 참조치 — 제2 임계값 아님**(단일 합격선은 0.70 하나). 매칭은 raw substring이 아니라 **외부 AI가 원문 JD·이력서 텍스트를 정규화·동의어 묶음(JD 요구사항 자체는 불변)한 토큰 집합을 토큰 경계로** 센다(raw substring 금지; ats.md R4 동일 원본; `Java`⊂`JavaScript` 거짓양성 차단). JD 미제공 시 N/A 처리하고 사유 기록 |
27
29
  | C9 | 직무명(target job title) 일치 | JD의 핵심 직무명이 본문에 등장 | JD 헤드라인 직무명을 본문(이력서 요약/타이틀, LinkedIn 헤드라인)에서 정확/근접 일치로 탐색 → 매치 **≥ 1** |
28
30
  | C10 | 키워드 스터핑 방지(과최적화) | 특정 키워드 반복이 과하지 않음 | C8 키워드 각각의 본문 등장 횟수 중 **어떤 단일 키워드도 6회 초과 금지**, 그리고 키워드 토큰이 전체 토큰에서 차지하는 비율 **< 6%**. 위반 키워드 개수 = 0 목표 |
29
31
  | C11 | 약어·풀네임 병기 | 핵심 약어가 풀네임과 함께 1회 이상 병기 | JD/본문 공통 약어(예: ATS, SQL, PM) 중 본문에 약어만 단독 등장하고 풀네임 동반이 0인 항목 개수 → **0 목표**(파서/사람 양쪽 매칭 보장) |
@@ -44,10 +46,12 @@
44
46
  - **C11 (약어):** 첫 등장 시 풀네임 병기(예: "ATS(Applicant Tracking System)") 추가.
45
47
  - **C12 (형식):** `export_cover_letter` 등으로 `.docx`/텍스트 PDF로 내보내도록 안내. 스캔 PDF는 텍스트 레이어 버전으로 재생성.
46
48
 
47
- 재검증 후에도 C8이 JD 미제공으로 N/A면, 사용자에게 대상 공고를 `save_job_posting`으로 저장하도록 안내하고 커버리지 측정을 보류한다. 모든 항목 통과 시에만 "ATS 호환 확인됨"으로 표시하고 대시보드 링크(`http://127.0.0.1:4319`)를 안내한다.
49
+ 재검증 후에도 C8이 JD 미제공으로 N/A면, 사용자에게 대상 공고를 `save_job_posting`으로 저장하도록 안내하고 커버리지 측정을 보류한다. **코어 파스세이프티(C1~C7·C12)가 모두 통과하면 "ATS 호환 확인됨"으로 표시**한다. **advisory 항목(C8~C11 키워드 커버리지 축)은 미달 시 전체 PASS를 막지 않고 갭으로 검출·보고**하며 최종 PASS 판정은 연결 AI가 JD·문맥에 따라 내린다(ats.md가 R5/R10을 advisory로 분리한 것과 동일선). 안내 시 대시보드 링크(`http://127.0.0.1:4319`)를 덧붙인다.
48
50
 
49
51
  ## 4. 출처 (Provenance)
50
52
 
53
+ > **증거 한계 고지(ats.md §4와 동일선):** Jobscan은 ATS 최적화 도구를 파는 벤더(파싱 취약성·매치율 중요도 과장 인센티브)이고, '~99.7% ATS 사용'·헤더/푸터 무시 비율·단일 vs 멀티컬럼 실패율 같은 **정량 효과(effect size)는 미검증**이다. **단일 합격선 0.70은 Jobscan(75/80%)을 운영화한 권장 출발점(ats.md R4 동일 원본)이며 비구속 advisory**다. (별개로 `fit-matching.md`가 소유한 InHerSight/CNBC의 비선형 바닥선 관찰은 *지원 결정* 도메인 리서치로, ATS 키워드 임계값 직접 보정 근거가 아니라 방향성 참고일 뿐이다.) 임계값은 advisory로만 쓰고 하드 게이트로 격상하지 마라.
54
+
51
55
  - **Jobscan — ATS Formatting Mistakes / Match Rate Guidance** — 표·다단·헤더/푸터·아이콘 금지, 연락처 본문 배치, 표준 헤더, 매치율 권장 75%+(스위트스폿 80%), 채용담당자 ~99.7% ATS 사용 통계 근거 · https://www.jobscan.co/blog/ats-formatting-mistakes/ , https://support.jobscan.co/hc/en-us/articles/41334833854099-What-match-rate-should-I-aim-for
52
56
  - **Jobscan — Tables & Columns Break Parsing** — 선형 파싱·다단/표가 텍스트 순서를 깨 "word salad"가 되는 메커니즘 근거 · https://www.jobscan.co/blog/resume-tables-columns-ats/
53
57
  - **Indeed Career Advice — How To Write an ATS Resume** — 표준 섹션 헤더(Work Experience/Education/Skills), 역연대기 형식, JD 키워드 자연 통합, 표준 폰트 권장 근거 · https://www.indeed.com/career-advice/resumes-cover-letters/ats-resume-template
@@ -74,12 +74,12 @@
74
74
  연결된 AI는 위 항목을 채점해 **불합격 항목 ID + 근거(센 수치/매칭된 문자열/위치)** 를 모은다. 그다음 항목별로 아래 수정 지시를 적용해 **재작성 → 동일 루브릭 재검증**한다. 재검증 통과까지 반복하되, **C8·C9(사실·고유명사 보존)는 단 1건이라도 위반 시 즉시 하드 실패**로 처리하고 다른 어떤 개선보다 우선해 복구한다.
75
75
 
76
76
  - **C1·C2·C6 위반(슬롭/상투/과장)**: 매칭된 단어·구문을 나열하고, 각각을 그 사람의 실제 경험·맥락에서 나온 **구체 표현으로 1:1 치환**한다. 일반론을 사실로 바꾼다. 치환 후 밀도를 다시 센다.
77
- - **C3·C4·C5 위반(단조로움)**: 긴 문장 1~2개를 쪼개 **짧은 강조 문장(≤8단어)** 을 삽입하고, 연속으로 같은 시작어를 쓴 문장의 도입부를 바꾼다. 3단 병렬을 1개로 줄이거나 문장 형태를 비대칭으로 푼다. 표준편차·시작어 분포를 재측정한다.
77
+ - **C3·C4·C5 위반(단조로움)**: **셋 다 권고 신호**이므로 위반이 있어도 재작성을 강제하지 않는다. 다음 조건 중 하나 이상이면(연속 3문장 이상 동일 시작어 / 단락 내 3단 병렬 2회 초과) 구체적 수정 제안을 함께 낸다(여전히 권고): 긴 문장 1~2개를 쪼개 **짧은 강조 문장(≤8단어)** 삽입, 연속 동일 시작어 문장의 도입부 변경, 3단 병렬을 1개로 축소·문장 형태 비대칭화. 표준편차·시작어 분포를 재측정한다.
78
78
  - **C7 위반(목소리 불일치)**: 다수 종결어미·인칭으로 **전체를 통일**한다. 격식이 튀는 구간을 주변 톤에 맞춰 다시 쓴다.
79
79
  - **C10·C11 위반(빈말/근거 부족)**: 빈말 문장을 삭제하거나, 원본 데이터(`get_application_context`로 받은 경력·성과·스킬)에서 **수치·사례를 끌어와** 구체화한다. 없으면 지어내지 말고 사용자에게 해당 수치를 묻는다.
80
80
  - **C8·C9 위반(사실/고유명사 손상)**: 원본에서 누락·왜곡된 숫자·고유명사를 **원문 그대로 복원**한다. 추정·대체 금지. 복원 후 집합 비교를 재수행해 100%를 확인한다.
81
81
 
82
- 재검증 종료 조건: **C1~C7·C10·C11 전부 합격 + C8·C9 위반 0건**. 한 항목이라도 불합격이면 루프를 멈추지 않는다.
82
+ 재검증 종료 조건: **C1·C2·C6·C7·C10·C11 전부 합격 + C8·C9 위반 0건**. **C3·C4·C5(문장 길이·시작어·3단 병렬 단조성 신호)는 게이트가 아니라 권고 신호**다 — 단독으로 종료를 막지 않고 개선 권고만 한다(짧은 문항·이력서 bullet 등 통계 무의미 구간 false-positive 방지; §2 advisory note·`human-writing.md` R12/line86과 동일선). 단 뚜렷한 위반(연속 3문장 이상 동일 시작어[C4] / 단락 내 3단 병렬 2회 초과[C5] — §2 표의 단락당 1회 기준을 크게 넘는 밀집)엔 구체 수정 제안을 함께 낸다(재작성 강제는 아님). 그 외 한 항목이라도 불합격이면 루프를 멈추지 않는다. (C3·C4·C5 게이트 정렬 완료 2026-06-20: 셋 다 권고 신호로 통일, human-writing.md 게이트 집합 R5/R6/R9/R11과 정합. cross-verifier 근거 구분은 §4 보존 — C5=Wikipedia rule-of-three, C3·C4=burstiness.)
83
83
 
84
84
  ## 4. 출처 (Provenance)
85
85
 
@@ -43,7 +43,7 @@
43
43
  1. **분류 먼저.** 각 불합격 항목을 (a) stale 입력 (b) false recency 라벨 (c) 버전 드리프트 (d) 날짜 누락 (e) 만료 자격/기간 중 하나로 라벨링한다.
44
44
  2. **(a) stale 입력 → 재수집 또는 강등.** stale한 연봉 벤치마크(C1)는 사용자가 **새로 수집**해 `save_compensation_benchmark`로 갱신한 뒤 재분석한다. 즉시 재수집이 불가하면 그 수치를 협상 앵커에서 빼고 "기준일 YYYY-MM, 신선도 주의(참고치)"로 강등 표기한다 — 낡은 수치를 현재 시세로 단정하지 않는다.
45
45
  3. **(b) false recency → 라벨 정정 또는 교체.** 6개월 초과 뉴스(C2)는 "최근" 라벨을 떼고 날짜를 명시(`2025-08 발표`)하거나, 라이브로 더 최신 뉴스를 찾아 교체한다. 12~18개월 초과는 `stale`로 표기해 면접 인용에서 제외한다.
46
- 4. **(c) 버전 드리프트 → 재분석 트리거.** 분석의 `resumeVersionId`·JD 해시가 최신과 다르면(C4), 최신 버전으로 적합도 분석·자소서를 **다시 생성**한다. 사용자에게 "이력서/공고가 바뀌어 이전 분석이 낡았다 → 다시 분석한다"를 쉬운 한국어로 안내한다.
46
+ 4. **(c) 버전 드리프트 → 재분석 트리거.** 분석의 `resumeVersionId`·JD 해시가 최신과 다르면(C4), 최신 버전으로 적합도 분석·자소서를 **다시 생성**한다. 사용자에게 "이력서/공고가 바뀌어 이전 분석이 낡았다 → 다시 분석한다"를 사용자의 언어로 쉽게 안내한다.
47
47
  5. **(d) 날짜 누락 → as-of 부착.** 날짜 없는 "최근/현재"(C5)에는 출처 기준일(`YYYY-MM`)을 붙인다. 기준일을 모르면 그 시점 주장을 정성 표현으로 일반화하거나 `[기준일 확인 필요]`로 표시한다(추측 날짜 금지 — 진실성 검증기와 연동).
48
48
  6. **(e) 만료 자격/기간 → 갱신 또는 표기.** 만료된 자격증(C6)은 "취득(만료, 갱신 필요)"로 표기하거나 사용자에게 갱신 여부를 묻는다. 종료된 포지션의 "현재 재직 중"(C7)은 실제 `end_date`로 정정하고, 미래 졸업/이수 예정은 "YYYY-MM 졸업예정"으로 표기한다.
49
49
  7. **재검증 게이트.** 수정본을 다시 C1~C9에 통과시킨다. critical(C1>12mo·C2 stale·C4 드리프트) 중 하나라도 남으면 루프를 반복한다. 시장 데이터 재수집이 사용자 액션을 요구하면(2회 시도 후에도 미해결), 해당 보상 수치를 산출물에서 보류하고 사용자에게 수집을 요청한다(낡은 값으로 무한 진행 금지).
@@ -34,7 +34,7 @@
34
34
  | C5 | 형식 토큰 충족 | 문항이 명시한 형식 요소가 모두 존재. 예: STAR 면접이면 Situation·Task·Action·Result 4요소, "구체적 사례" 요구면 사례 1건+ | det/D · 형식 체크리스트별 대응 토큰/구간 존재 카운트 → 누락 요소 수 = 0 |
35
35
  | C6 | 글자 수/분량 준수 | 자소서 문항의 **글자 수 상·하한**을 지킴(한국 공채 자사양식의 핵심 탈락 사유). 범위 내 | det/D · 응답 길이(공백 포함/제외 둘 다) vs 문항 제한. 초과·미달 글자 수 출력 |
36
36
  | C7 | 질문 직접 응답성 | 응답이 질문에 **직접** 답하고 우회·일반론으로 빠지지 않음(off-target/회피 0건) | ai/H · 연결 AI가 "이 응답이 이 질문에 답하는가"를 Y/N 판정. N이면 빗나간 지점 명시 |
37
- | C8 | JD 필수 요건 커버리지 | JD의 **필수(required) 요건** 항목 중 산출물(이력서+자소서)에서 다뤄진 비율 **≥ 80%**, 필수 요건 미커버 **0개** | det/D · JD에서 필수/우대 요건 분리 추출 → 각 요건 키워드가 산출물에 존재하는지 매칭 → (커버 요건 ÷ 전체 필수). 미커버 필수 요건 나열 |
37
+ | C8 | JD 필수 요건 커버리지 | JD의 **필수(required) 요건** 항목 중 산출물(이력서+자소서)에서 다뤄진 비율 **≥ 80%**, 필수 요건 미커버 **0개**. (이 ≥80%는 응답성 커버리지 신호이며 적합(fit) 권장 게이트가 아니다 — 적합 판정은 `fit-matching`/`fit-analysis`가 하드요건(knockout)으로 내린다. C8 자체는 응답성 루프 내 critical 검사로, 미달은 적합·지원을 차단하는 게 아니라 §3 보강 재작성을 트리거한다.) | det/D · JD에서 필수/우대 요건 분리 추출 → 각 요건 키워드가 산출물에 존재하는지 매칭 → (커버 요건 ÷ 전체 필수). 미커버 필수 요건 나열 |
38
38
  | C9 | JD 우대 요건 커버리지 | **우대(preferred)** 요건 커버 비율 보고(차단 아님, 경고). 권장 ≥ 50% | det/D · C8과 동일 로직을 우대 요건에 적용. 비율만 리포트, 미달은 warning |
39
39
  | C10 | 과잉/딴소리 비율 | 응답 중 질문과 무관한 문장(Q-keyword·directive 어느 것과도 무관) 비율 **< 20%** | det/D + ai/H · 각 문장을 질문 관련/무관으로 태깅(키워드 매칭=det, 환언 무관 판정=ai) → 무관 문장 비율 |
40
40
  | C11 | 질문 전제·제약 준수 | 질문이 건 제약(예: "**팀** 경험", "**실패** 사례", "최근 3년 내")을 응답이 위반하지 않음 | ai/H · 질문의 한정어(팀/개인, 성공/실패, 기간) 추출 → 응답 사례가 그 한정과 일치하는지 판정. 위반 사례 개수 = 0 |
@@ -42,7 +42,7 @@
42
42
  ### Locale 분기 (한국 vs 글로벌)
43
43
 
44
44
  - **한국(공채·자사양식):** C5·C6이 1차 게이트. 자사양식 문항은 **글자 수 제한이 엄격**하고(C6), "성장과정/지원동기/직무역량/입사 후 포부" 같은 **정형 문항 유형**이 directive를 규정한다(C2). 자소서에 별도 문항이 없고 "자유 기술"이면 C1·C2는 **인재상·직무기술서 키워드**를 질문 대용으로 사용. 포괄임금·공채 특성상 JD가 느슨할 수 있어 C8은 **채용공고+직무기술서+인재상**을 합쳐 요건 풀로 만든다.
45
- - **글로벌(JD 중심·LinkedIn):** C8·C9가 1차 게이트. JD의 required/preferred 분리와 키워드 커버리지(ATS 정합)가 핵심. 면접은 STAR(C5) 중심. cover letter는 보통 단일 자유 양식이라 C6 대신 **JD 요건 응답성(C8)** 이 주 축.
45
+ - **글로벌(JD 중심·LinkedIn):** C8·C9가 응답성 루프 내 1차 게이트(fit 권장 게이트는 아님 — 적합 판정은 fit-matching/fit-analysis가 내린다). JD의 required/preferred 분리와 키워드 커버리지(ATS 정합)가 핵심. 면접은 STAR(C5) 중심. cover letter는 보통 단일 자유 양식이라 C6 대신 **JD 요건 응답성(C8)** 이 주 축.
46
46
 
47
47
  ## 3. 불합격 시 처리(루프 연결)
48
48
 
@@ -43,7 +43,7 @@
43
43
  2. **(a) 날조 → 삭제.** 저장 근거가 0인 회사·직함·수치·자격증은 문장째 삭제하거나, 저장된 실제 사실로 교체한다. 지어내서 채우지 않는다.
44
44
  3. **(b) 과장 → 하향 정렬.** 직함·귀속·숙련도를 저장 데이터 수준으로 되돌린다(팀장→팀원, 주도→참여, 전문가→"3년 사용"). 표현은 강하게 두되 **사실 레벨만** 내린다.
45
45
  4. **(c) 미검증 정밀도 → 일반화 또는 표식.** 근거 없는 "37%"는 (저장값이 있으면) 정확값으로 바꾸고, 없으면 정성 표현("크게 단축")으로 일반화하거나 `[확인 필요: 수치 출처]`로 표시한다. 추측 수치를 남기지 않는다.
46
- 5. **(d) 갭 은폐 → 정직한 표식 복원.** 지어낸 빈칸 채움을 `[확인 필요]`로 되돌리고, 사용자에게 해당 사실을 **쉬운 한국어로** 질문한다(직함·기술 용어 노출 금지). 사용자가 값을 주면 저장 도구로 보관 후 반영한다.
46
+ 5. **(d) 갭 은폐 → 정직한 표식 복원.** 지어낸 빈칸 채움을 `[확인 필요]`로 되돌리고, 사용자에게 해당 사실을 **사용자의 언어로 쉽게** 질문한다(직함·기술 용어 노출 금지). 사용자가 값을 주면 저장 도구로 보관 후 반영한다.
47
47
  6. **재검증 게이트.** 수정본을 다시 C1~C10에 통과시킨다. critical 4종 중 하나라도 남으면 루프를 반복한다. 2회 반복 후에도 미앵커가 남으면, 해당 문장을 산출물에서 보류하고 사용자에게 확인을 요청한다(무한정 생성 금지).
48
48
  7. **저장.** 통과한 산출물만 저장 도구(`save_cover_letter_version`·`save_fit_analysis`·`save_interview_prep` 등)로 보관한다. 검증 결과 요약(미앵커 0, traceability=1.0)을 산출물 메타에 함께 남긴다.
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careermate",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "CareerMate — an MCP-first, local-first career workspace. Connect it to a local AI agent (Claude Desktop, Claude Code, Codex); CareerMate stores your career data locally on your machine and exposes it over MCP.",
6
6
  "license": "MIT",
@@ -66,7 +66,7 @@
66
66
  "shots:seed": "node --no-warnings --experimental-sqlite --import tsx scripts/shots-seed.ts",
67
67
  "shots": "node --no-warnings --experimental-sqlite --import tsx scripts/shots.ts",
68
68
  "doctor": "node --no-warnings --experimental-sqlite --import tsx scripts/doctor.ts",
69
- "test": "node --no-warnings scripts/run.mjs scripts/test-init.ts \"INIT_TEST_VERDICT PASS\" && node --no-warnings scripts/run.mjs scripts/test.ts \"TEST_VERDICT PASS\"",
69
+ "test": "node --no-warnings scripts/check-i18n-keys.mjs && node --no-warnings scripts/run.mjs scripts/test-i18n.ts \"I18N_VERDICT PASS\" && node --no-warnings scripts/run.mjs scripts/test-init.ts \"INIT_TEST_VERDICT PASS\" && node --no-warnings scripts/run.mjs scripts/test-bridge.ts \"BRIDGE_TEST_VERDICT PASS\" && node --no-warnings scripts/run.mjs scripts/test.ts \"TEST_VERDICT PASS\"",
70
70
  "test:ui": "node --no-warnings scripts/run.mjs scripts/ui-smoke.ts \"UI_VERDICT PASS\"",
71
71
  "typecheck": "tsc --noEmit"
72
72
  },
@@ -40,13 +40,14 @@ export function profileCompleteness(p: ProfileRecord | null): number {
40
40
  export function getOnboardingStatus(): OnboardingStatus {
41
41
  const profile = profileRepo.get();
42
42
  const resumes = documentRepo.list('resume');
43
+ const careerDescriptions = documentRepo.list('career_description');
43
44
  const coverLetters = coverLetterRepo.list();
44
45
  const experiences = experienceRepo.list();
45
46
  const skills = skillRepo.list();
46
47
  const jobs = jobRepo.list();
47
48
 
48
49
  const has_profile = !!(profile && profile.name);
49
- const has_resume = resumes.length > 0;
50
+ const has_resume = resumes.length > 0 || careerDescriptions.length > 0;
50
51
  const has_cover_letter = coverLetters.length > 0;
51
52
  const has_experience = experiences.length > 0;
52
53
  const has_skills = skills.length > 0;
@@ -13,11 +13,14 @@ import {
13
13
  type FitAnalysisRecord,
14
14
  type CoverLetterVersionInput,
15
15
  type ApplicationStatus,
16
+ type ApplicationSubmission,
16
17
  type ApplicationRecord,
17
18
  type InterviewPrepInput,
18
19
  type InterviewPrepRecord,
19
20
  type DocumentInput,
20
21
  type DocumentRecord,
22
+ type CoverLetterRecord,
23
+ type CoverLetterVersionRecord,
21
24
  type ProfileInput,
22
25
  type ProfileRecord,
23
26
  type ExperienceInput,
@@ -30,7 +33,6 @@ import {
30
33
  INTERVIEW_UNLOCK_STATUSES,
31
34
  CareerMateError,
32
35
  notFound,
33
- conflict,
34
36
  } from '@careermate/shared';
35
37
  import {
36
38
  jobRepo,
@@ -44,7 +46,9 @@ import {
44
46
  projectRepo,
45
47
  skillRepo,
46
48
  activityRepo,
49
+ timelineRepo,
47
50
  getVerifyStrict,
51
+ tx,
48
52
  } from '@careermate/db';
49
53
  import { lintArtifact, type VerifyCorpus, type LintReport } from './verify/index.ts';
50
54
 
@@ -143,6 +147,39 @@ function batchNames(names: string[]): string {
143
147
  return `: ${shown}${names.length > 8 ? ' 외' : ''}`;
144
148
  }
145
149
 
150
+ function coverLetterTimelineRef(
151
+ coverLetter: CoverLetterRecord,
152
+ version?: CoverLetterVersionRecord | null,
153
+ requestedVersionId?: string | null,
154
+ ) {
155
+ const resolvedVersion =
156
+ version ??
157
+ coverLetter.versions?.find((v) => v.id === requestedVersionId || v.id === coverLetter.current_version_id) ??
158
+ null;
159
+ return {
160
+ kind: 'cover_letter',
161
+ id: coverLetter.id,
162
+ title: coverLetter.title,
163
+ version_id: resolvedVersion?.id ?? requestedVersionId ?? coverLetter.current_version_id,
164
+ version_no: resolvedVersion?.version_no ?? coverLetter.version_count,
165
+ };
166
+ }
167
+
168
+ function documentTimelineRef(doc: DocumentRecord) {
169
+ return {
170
+ kind: 'document',
171
+ id: doc.id,
172
+ title: doc.title,
173
+ document_kind: doc.kind,
174
+ };
175
+ }
176
+
177
+ function submittedAtToOccurrence(value?: string | null): string | undefined {
178
+ if (!value) return undefined;
179
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`;
180
+ return undefined;
181
+ }
182
+
146
183
  export function addExperiences(inputs: ExperienceInput[]): {
147
184
  records: ExperienceRecord[];
148
185
  created: number;
@@ -202,11 +239,22 @@ export function saveJobPosting(input: JobInput, id?: string): { job: JobRecord;
202
239
 
203
240
  export function saveFitAnalysis(input: FitAnalysisInput): FitAnalysisRecord {
204
241
  const job = jobRepo.get(input.job_id);
205
- if (!job) throw notFound(`공고를 찾을 수 없습니다: ${input.job_id}. 먼저 save_job_posting으로 공고를 저장하세요.`);
242
+ if (!job) throw notFound('공고를 찾을 수 없습니다. 먼저 공고를 저장한 뒤 다시 시도하세요.');
206
243
  applicationRepo.ensure(job.id, 'draft');
207
244
  const fit = fitRepo.save(input);
208
245
  const scoreText = fit.score != null ? ` (적합도 ${fit.score}점)` : '';
209
246
  activityRepo.log('fit_analysis_saved', `${job.company} · ${job.position} 적합도 분석을 저장했습니다${scoreText}.`, 'fit_analysis', fit.id);
247
+ timelineRepo.add({
248
+ job_id: job.id,
249
+ type: 'fit_analysis_saved',
250
+ title: '적합도 분석',
251
+ summary: fit.score != null ? `종합 ${fit.score}점` : fit.summary ?? undefined,
252
+ payload: {
253
+ fit_id: fit.id,
254
+ score: fit.score,
255
+ },
256
+ occurred_at: fit.updated_at,
257
+ });
210
258
  return fit;
211
259
  }
212
260
 
@@ -251,6 +299,18 @@ export function saveCoverLetterVersion(input: CoverLetterVersionInput & { force?
251
299
  'cover_letter',
252
300
  coverLetter.id,
253
301
  );
302
+ if (coverLetter.job_id) {
303
+ timelineRepo.add({
304
+ job_id: coverLetter.job_id,
305
+ type: 'cover_letter_version_saved',
306
+ title: '자기소개서 작성',
307
+ summary: `${coverLetter.title} v${version.version_no}`,
308
+ payload: {
309
+ cover_letter: coverLetterTimelineRef(coverLetter, version),
310
+ },
311
+ occurred_at: version.created_at,
312
+ });
313
+ }
254
314
  return { coverLetter, version, verification, forced };
255
315
  }
256
316
 
@@ -267,22 +327,75 @@ export function updateApplicationStatus(
267
327
  jobId: string,
268
328
  status: ApplicationStatus,
269
329
  note?: string,
330
+ submission?: ApplicationSubmission,
270
331
  ): StatusChangeResult {
271
332
  const job = jobRepo.get(jobId);
272
- if (!job) throw notFound(`공고를 찾을 수 없습니다: ${jobId}`);
273
- const application = applicationRepo.setStatus(jobId, status, note);
274
- activityRepo.log(
275
- 'application_status_changed',
276
- `${job.company} · ${job.position} 상태를 '${APPLICATION_STATUS_LABELS[status]}'(으)로 변경했습니다.`,
277
- 'application',
278
- application.id,
279
- );
333
+ if (!job) throw notFound('공고를 찾을 수 없습니다.');
334
+ const before = applicationRepo.getByJob(jobId);
335
+ // Status change, submission upsert, activity log, and timeline entry must land
336
+ // together: if any write throws (e.g. a bad bind), roll back so we never leave
337
+ // the status changed but the submission/timeline missing.
338
+ const application = tx(() => {
339
+ let application = applicationRepo.setStatus(jobId, status, note);
340
+ const submittedDocs = (submission?.document_ids ?? [])
341
+ .map((docId) => documentRepo.get(docId))
342
+ .filter((doc): doc is DocumentRecord => !!doc);
343
+ const submittedCoverLetter = submission?.cover_letter_id
344
+ ? coverLetterRepo.get(submission.cover_letter_id, true)
345
+ : null;
346
+ if (status === 'applied' && submission) {
347
+ const resumeLike =
348
+ submittedDocs.find((doc) => doc.kind === 'resume' || doc.kind === 'career_description') ??
349
+ submittedDocs[0] ??
350
+ null;
351
+ application = applicationRepo.upsert({
352
+ job_id: jobId,
353
+ resume_id: resumeLike?.id ?? application.resume_id ?? undefined,
354
+ cover_letter_id: submittedCoverLetter?.id ?? application.cover_letter_id ?? undefined,
355
+ applied_at: submission.submitted_at ?? application.applied_at ?? undefined,
356
+ });
357
+ }
358
+ activityRepo.log(
359
+ 'application_status_changed',
360
+ `${job.company} · ${job.position} 상태를 '${APPLICATION_STATUS_LABELS[status]}'(으)로 변경했습니다.`,
361
+ 'application',
362
+ application.id,
363
+ );
364
+ const submittedAt = submission?.submitted_at?.trim();
365
+ const channel = submission?.channel?.trim();
366
+ const submissionPayload =
367
+ submission && (submittedAt || channel || submittedCoverLetter || submittedDocs.length > 0)
368
+ ? {
369
+ submitted_at: submittedAt || null,
370
+ channel: channel || null,
371
+ cover_letter: submittedCoverLetter
372
+ ? coverLetterTimelineRef(submittedCoverLetter, null, submission.cover_letter_version_id)
373
+ : null,
374
+ documents: submittedDocs.map(documentTimelineRef),
375
+ }
376
+ : null;
377
+ timelineRepo.add({
378
+ job_id: job.id,
379
+ type: 'application_status_changed',
380
+ title: APPLICATION_STATUS_LABELS[status],
381
+ summary: channel && status === 'applied' ? `${channel}로 제출` : note ?? undefined,
382
+ payload: {
383
+ from_status: before?.status ?? null,
384
+ status,
385
+ status_label: APPLICATION_STATUS_LABELS[status],
386
+ note: note ?? null,
387
+ submission: submissionPayload,
388
+ },
389
+ occurred_at: submittedAtToOccurrence(submittedAt),
390
+ });
391
+ return application;
392
+ });
280
393
 
281
394
  const interview_unlocked = INTERVIEW_UNLOCK_STATUSES.includes(status);
282
395
  const hasPrep = !!interviewRepo.getByJob(jobId);
283
396
  const hint =
284
397
  interview_unlocked && !hasPrep
285
- ? '서류 단계를 통과했어요. 이 공고 기준으로 예상 면접 질문과 1분 자기소개를 준비할까요? (save_interview_prep)'
398
+ ? '서류 단계를 통과했어요. 이 공고 기준으로 예상 면접 질문과 1분 자기소개를 준비할까요?'
286
399
  : null;
287
400
 
288
401
  return { application, job, interview_unlocked, hint };
@@ -292,17 +405,21 @@ export function updateApplicationStatus(
292
405
 
293
406
  export function saveInterviewPrep(input: InterviewPrepInput): InterviewPrepRecord {
294
407
  const job = jobRepo.get(input.job_id);
295
- if (!job) throw notFound(`공고를 찾을 수 없습니다: ${input.job_id}`);
296
- // Spec gate: interview prep is only allowed once the application has reached
297
- // 서류 합격(document_passed) or beyond. Enforce it server-side, not just in prompts.
298
- const app = applicationRepo.getByJob(job.id);
299
- if (!app || !INTERVIEW_UNLOCK_STATUSES.includes(app.status)) {
300
- const current = app ? APPLICATION_STATUS_LABELS[app.status] : '미지원';
301
- throw conflict(
302
- `면접 준비는 '서류 합격' 이상 상태에서만 저장할 수 있습니다. 현재 상태: '${current}'. 먼저 update_application_status로 상태를 올린 뒤 다시 시도하세요.`,
303
- );
304
- }
408
+ if (!job) throw notFound('공고를 찾을 수 없습니다.');
409
+ // Interview prep can be drafted before a formal document pass. Status still
410
+ // controls when the dashboard recommends it as a to-do, not whether it can be saved.
305
411
  const prep = interviewRepo.save(input);
306
412
  activityRepo.log('interview_prep_saved', `${job.company} · ${job.position} 면접 준비 자료를 저장했습니다.`, 'interview_prep', prep.id);
413
+ timelineRepo.add({
414
+ job_id: job.id,
415
+ type: 'interview_prep_saved',
416
+ title: '면접 준비',
417
+ summary: prep.questions.length > 0 ? `예상 질문 ${prep.questions.length}개` : prep.notes ?? undefined,
418
+ payload: {
419
+ interview_prep_id: prep.id,
420
+ question_count: prep.questions.length,
421
+ },
422
+ occurred_at: prep.updated_at,
423
+ });
307
424
  return prep;
308
425
  }