careermate 0.0.5 → 0.0.7

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 (125) hide show
  1. package/AGENTS.md +13 -10
  2. package/INSTALL.md +16 -15
  3. package/README.md +24 -24
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +12 -6
  7. package/dist/career-os/eop/career-description.md +139 -0
  8. package/dist/career-os/eop/cover-letter.md +10 -5
  9. package/dist/career-os/eop/fit-analysis.md +16 -6
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +6 -4
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +5 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/human-writing.md +2 -0
  16. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  17. package/dist/career-os/knowledge/interview-technical.md +106 -4
  18. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  19. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  20. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  21. package/dist/career-os/knowledge/portfolio.md +3 -3
  22. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  23. package/dist/career-os/knowledge/verifiers/human-voice.md +4 -2
  24. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  25. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  26. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  27. package/dist/doctor.mjs +692 -71
  28. package/dist/mcp.mjs +710 -71
  29. package/dist/migrate.mjs +52 -1
  30. package/dist/public/app.js +42 -16
  31. package/dist/public/i18n/en/applications.js +23 -0
  32. package/dist/public/i18n/en/common.js +22 -0
  33. package/dist/public/i18n/en/documents.js +133 -0
  34. package/dist/public/i18n/en/home.js +75 -0
  35. package/dist/public/i18n/en/interview.js +69 -0
  36. package/dist/public/i18n/en/jobs.js +152 -0
  37. package/dist/public/i18n/en/nav.js +18 -0
  38. package/dist/public/i18n/en/profile.js +201 -0
  39. package/dist/public/i18n/en/settings.js +127 -0
  40. package/dist/public/i18n/en/status.js +22 -0
  41. package/dist/public/i18n/en.js +17 -0
  42. package/dist/public/i18n/ko/applications.js +23 -0
  43. package/dist/public/i18n/ko/common.js +22 -0
  44. package/dist/public/i18n/ko/documents.js +133 -0
  45. package/dist/public/i18n/ko/home.js +63 -0
  46. package/dist/public/i18n/ko/interview.js +69 -0
  47. package/dist/public/i18n/ko/jobs.js +152 -0
  48. package/dist/public/i18n/ko/nav.js +18 -0
  49. package/dist/public/i18n/ko/profile.js +201 -0
  50. package/dist/public/i18n/ko/settings.js +127 -0
  51. package/dist/public/i18n/ko/status.js +23 -0
  52. package/dist/public/i18n/ko.js +18 -0
  53. package/dist/public/i18n.js +55 -0
  54. package/dist/public/index.html +1 -0
  55. package/dist/public/lib.js +24 -17
  56. package/dist/public/pages/applications.js +17 -21
  57. package/dist/public/pages/documents.js +153 -107
  58. package/dist/public/pages/home.js +88 -53
  59. package/dist/public/pages/interview.js +105 -51
  60. package/dist/public/pages/jobs.js +330 -106
  61. package/dist/public/pages/profile.js +157 -152
  62. package/dist/public/pages/settings.js +154 -102
  63. package/dist/public/styles.css +28 -1
  64. package/dist/web.mjs +495 -75
  65. package/docs/FAQ.md +23 -3
  66. package/docs/MCP_TOOLS.md +22 -13
  67. package/docs/START_WORKFLOW.md +3 -3
  68. package/docs/SUPPORTED_AI_APPS.md +13 -0
  69. package/docs/WORKFLOWS.md +77 -20
  70. package/docs/career-os/README.md +12 -6
  71. package/docs/career-os/eop/career-description.md +139 -0
  72. package/docs/career-os/eop/cover-letter.md +10 -5
  73. package/docs/career-os/eop/fit-analysis.md +16 -6
  74. package/docs/career-os/eop/interview-prep.md +209 -21
  75. package/docs/career-os/eop/job-analysis.md +6 -4
  76. package/docs/career-os/eop/profile-extraction.md +5 -4
  77. package/docs/career-os/knowledge/cover-letter.md +5 -4
  78. package/docs/career-os/knowledge/fit-matching.md +8 -8
  79. package/docs/career-os/knowledge/human-writing.md +2 -0
  80. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  81. package/docs/career-os/knowledge/interview-technical.md +106 -4
  82. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  83. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  84. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  85. package/docs/career-os/knowledge/portfolio.md +3 -3
  86. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  87. package/docs/career-os/knowledge/verifiers/human-voice.md +4 -2
  88. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  89. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  90. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  91. package/package.json +2 -2
  92. package/packages/core/src/onboarding.ts +2 -1
  93. package/packages/core/src/services.ts +134 -17
  94. package/packages/core/src/summary.ts +59 -1
  95. package/packages/core/src/verify/lint.ts +4 -0
  96. package/packages/db/src/index.ts +1 -0
  97. package/packages/db/src/repositories.ts +0 -0
  98. package/packages/db/src/schema.ts +17 -0
  99. package/packages/exporters/src/index.ts +28 -17
  100. package/packages/knowledge/src/index.ts +55 -9
  101. package/packages/mcp-tools/src/tools.ts +250 -24
  102. package/packages/mcp-tools/src/update.ts +30 -15
  103. package/packages/parsers/src/documents.ts +12 -1
  104. package/packages/prompts/src/humanize.ts +46 -0
  105. package/packages/prompts/src/index.ts +5 -1
  106. package/packages/prompts/src/install.ts +4 -4
  107. package/packages/prompts/src/onboarding.ts +4 -4
  108. package/packages/prompts/src/system.ts +9 -7
  109. package/packages/shared/src/enums.ts +14 -4
  110. package/packages/shared/src/schemas.ts +41 -0
  111. package/packages/workflows/src/definitions.ts +45 -8
  112. package/scripts/init.ts +284 -13
  113. package/dist/site/analytics.js +0 -97
  114. package/dist/site/favicon.svg +0 -1
  115. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  116. package/dist/site/icon-180.png +0 -0
  117. package/dist/site/icon-512.png +0 -0
  118. package/dist/site/index.html +0 -465
  119. package/dist/site/landing.js +0 -163
  120. package/dist/site/llms-install.txt +0 -218
  121. package/dist/site/robots.txt +0 -4
  122. package/dist/site/sitemap.xml +0 -21
  123. package/dist/site/start.html +0 -356
  124. package/dist/site/style.css +0 -665
  125. package/dist/site/vercel.json +0 -20
@@ -58,12 +58,13 @@ export const VERIFIER_IDS = [
58
58
  ] as const;
59
59
  export type VerifierId = (typeof VERIFIER_IDS)[number];
60
60
 
61
- /** 5 per-feature execution procedures — docs/career-os/eop/<feature>.md. */
61
+ /** Per-feature execution procedures — docs/career-os/eop/<feature>.md. */
62
62
  export const EOP_FEATURES = [
63
63
  'fit-analysis',
64
64
  'job-analysis',
65
65
  'profile-extraction',
66
66
  'cover-letter',
67
+ 'career-description',
67
68
  'interview-prep',
68
69
  ] as const;
69
70
  export type EopFeature = (typeof EOP_FEATURES)[number];
@@ -95,7 +96,7 @@ export function getEop(feature: EopFeature): string {
95
96
 
96
97
  /** A goal → which EOP, expert playbooks, and verifiers to apply, in order. */
97
98
  export interface CareerRoute {
98
- eop?: EopFeature;
99
+ eop?: EopFeature | EopFeature[];
99
100
  expertSequence: ExpertDomain[];
100
101
  verifierSequence: VerifierId[];
101
102
  loop?: 'draft_verify_revise';
@@ -109,23 +110,65 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
109
110
  verifierSequence: ['truthfulness', 'consistency'],
110
111
  },
111
112
  analyze_job: {
112
- eop: 'job-analysis',
113
+ eop: ['job-analysis', 'fit-analysis'],
113
114
  expertSequence: ['fit-matching', 'company-research'],
114
115
  verifierSequence: ['truthfulness', 'consistency', 'responsiveness-on-target'],
115
116
  loop: 'draft_verify_revise',
116
117
  },
118
+ // R22(audit): responsiveness-on-target는 한국 문항형 자소서 전용 검증기(문항별 1:1 응답·
119
+ // 글자수 상·하한 = "핵심 탈락 사유")인데 analyze_job에만 라우팅돼 있어 정작 자소서 자가검증
120
+ // 루프에서 빠져 있었다. 동문서답·미응답 문항·글자수 초과를 잡도록 write_cover_letter에 추가.
117
121
  write_cover_letter: {
118
122
  eop: 'cover-letter',
119
123
  expertSequence: ['cover-letter', 'human-writing', 'company-research'],
120
- verifierSequence: ['human-voice', 'truthfulness', 'ats-compat', 'consistency'],
124
+ verifierSequence: ['human-voice', 'truthfulness', 'responsiveness-on-target', 'ats-compat', 'consistency'],
125
+ loop: 'draft_verify_revise',
126
+ },
127
+ write_career_description: {
128
+ eop: 'career-description',
129
+ expertSequence: ['resume', 'ats'],
130
+ verifierSequence: ['truthfulness', 'consistency', 'ats-compat'],
121
131
  loop: 'draft_verify_revise',
122
132
  },
123
133
  prepare_interview: {
124
134
  eop: 'interview-prep',
125
- expertSequence: ['interview-behavioral', 'interview-technical', 'company-research'],
126
- verifierSequence: ['truthfulness', 'consistency'],
135
+ expertSequence: ['recruiter-screen', 'interview-behavioral', 'interview-technical', 'company-research'],
136
+ // R22(audit): recency-staleness 추가 — 면접의 "최근 소식"·회사 근황이 낡으면 거짓 신선도가
137
+ // 되므로(company-research) 저장 전 신선도 점검을 푸시한다.
138
+ verifierSequence: ['truthfulness', 'consistency', 'recency-staleness'],
127
139
  loop: 'draft_verify_revise',
128
140
  },
141
+ // R7 A2: manage_application_status had no route → rejection-triage / offer-evaluation /
142
+ // salary-negotiation were orphaned (served only on-demand via get_playbook). Route the
143
+ // decision-stage playbooks so they are pushed when the user manages application outcomes.
144
+ // R7 A4: A2 routed the decision-stage playbooks, but the EOP-free lifecycle domains
145
+ // onboarding-first-90-days and networking-referrals were still on-demand-only. Anchor them
146
+ // here (the only EOP-free lifecycle route). NOTE: networking's strongest lever is *before*
147
+ // applied (pre-referral, networking-referrals §2) — this route is the surfacing point, not
148
+ // the recommended timing, so surface networking early when the user is pre-application.
149
+ // linkedin-profile/portfolio stay on-demand pending a dedicated personal-brand workflow id
150
+ // (FIXES.md / STATUS §7.9 follow-up — onboarding EOP is résumé-import-only, so brand-
151
+ // creation playbooks would blur its stage meaning). Additive wiring (A1/A2 precedent).
152
+ manage_application_status: {
153
+ expertSequence: [
154
+ 'rejection-triage-iteration',
155
+ 'offer-evaluation-decision',
156
+ 'salary-negotiation',
157
+ 'onboarding-first-90-days',
158
+ 'networking-referrals',
159
+ ],
160
+ // R22(audit): recency-staleness 추가 — 연봉 벤치마크·오퍼 비교는 시점이 지나면 낡은 기준이
161
+ // 되므로(salary-negotiation/offer-evaluation) 저장 전 신선도 점검을 푸시한다.
162
+ verifierSequence: ['truthfulness', 'consistency', 'recency-staleness'],
163
+ },
164
+ // R7 후속: linkedin-profile·portfolio는 onboarding(이력서 임포트)과 단계 의미가
165
+ // 달라(개인 브랜드 자산 *생성*) 전용 워크플로우로 분리(consensus 권고). networking은
166
+ // manage_application_status에 이미 정박. 저장은 add_resume(kind=portfolio); linkedin
167
+ // 전용 저장/검증은 Phase B.
168
+ build_personal_brand: {
169
+ expertSequence: ['linkedin-profile', 'portfolio'],
170
+ verifierSequence: ['truthfulness', 'human-voice', 'consistency'],
171
+ },
129
172
  };
130
173
 
131
174
  export function getRoute(id: string): CareerRoute | undefined {
@@ -142,12 +185,15 @@ export function renderRouteGuide(id: string): string | undefined {
142
185
  const route = CAREER_ROUTES[id];
143
186
  if (!route) return undefined;
144
187
  const lines: string[] = [];
145
- if (route.eop) {
188
+ const eops = route.eop ? (Array.isArray(route.eop) ? route.eop : [route.eop]) : [];
189
+ if (eops.length) {
146
190
  lines.push('---', '');
147
191
  lines.push('## 전문가 실행 절차 (EOP) — 즉답하지 말고 이 절차를 따르세요');
148
192
  lines.push('');
149
- lines.push(getEop(route.eop));
150
- lines.push('');
193
+ for (const e of eops) {
194
+ lines.push(getEop(e));
195
+ lines.push('');
196
+ }
151
197
  }
152
198
  lines.push('## 이 작업에 적용할 전문가 플레이북');
153
199
  lines.push('작성 직전, 아래 도메인의 깊은 지식을 받아 적용하세요:');
@@ -21,6 +21,7 @@ import {
21
21
  JobInputSchema,
22
22
  FitAnalysisInputSchema,
23
23
  CoverLetterVersionInputSchema,
24
+ ApplicationSubmissionSchema,
24
25
  InterviewPrepInputSchema,
25
26
  DocumentInputSchema,
26
27
  APPLICATION_STATUSES,
@@ -63,7 +64,7 @@ import {
63
64
  previewCoverLetter,
64
65
  summarizeReport,
65
66
  } from '@careermate/core';
66
- import { HUMANIZE_WRITING_GUIDE } from '@careermate/prompts';
67
+ import { getWritingStyleGuide } from '@careermate/prompts';
67
68
  import { getWorkflow, renderWorkflowMarkdown, WORKFLOWS } from '@careermate/workflows';
68
69
  import {
69
70
  getPlaybook,
@@ -75,7 +76,14 @@ import {
75
76
  type ExpertDomain,
76
77
  type VerifierId,
77
78
  } from '@careermate/knowledge';
78
- import { coverLetterToMarkdown, coverLetterToHtml } from '@careermate/exporters';
79
+ import {
80
+ coverLetterToMarkdown,
81
+ coverLetterToHtml,
82
+ resumeToMarkdown,
83
+ resumeToHtml,
84
+ profileToMarkdown,
85
+ profileToHtml,
86
+ } from '@careermate/exporters';
79
87
  import { extractDocument } from '@careermate/parsers';
80
88
  import { ok, fail, type ToolDef } from './result.ts';
81
89
  import { resolveDashboardUrl, openInBrowser, openInFileManager } from './bridge.ts';
@@ -93,15 +101,28 @@ function listInboxFiles(dir: string): { name: string; path: string }[] {
93
101
  } catch {
94
102
  return [];
95
103
  }
104
+ // Resolve the inbox itself once so we can confine listed files to it: a symlink
105
+ // dropped in the inbox whose real target escapes the inbox (e.g. → ~/.ssh/id_rsa
106
+ // or a private doc elsewhere) must not be listed or later read by read_inbox.
107
+ let realDir: string;
108
+ try {
109
+ realDir = fs.realpathSync.native(dir);
110
+ } catch {
111
+ realDir = dir;
112
+ }
96
113
  const SKIP = /^[.~]|^(thumbs\.db|desktop\.ini)$/i;
97
114
  const out: { name: string; path: string }[] = [];
98
115
  for (const name of names) {
99
116
  if (SKIP.test(name)) continue;
100
117
  const p = path.join(dir, name);
101
118
  try {
102
- if (fs.statSync(p).isFile()) out.push({ name, path: p });
119
+ if (!fs.statSync(p).isFile()) continue;
120
+ const real = fs.realpathSync.native(p);
121
+ const rel = path.relative(realDir, real);
122
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue; // escapes the inbox — skip
123
+ out.push({ name, path: p });
103
124
  } catch {
104
- /* unreadable entry — skip */
125
+ /* unreadable entry / broken link — skip */
105
126
  }
106
127
  }
107
128
  return out;
@@ -125,13 +146,15 @@ async function mapWithLimit<T, R>(items: T[], limit: number, fn: (item: T, index
125
146
  return out;
126
147
  }
127
148
 
128
- /** Statuses at/after which interview prep is unlocked (mirror of INTERVIEW_UNLOCK_STATUSES). */
149
+ /** Statuses at/after which interview prep is recommended as a next step. */
129
150
  const INTERVIEW_UNLOCKED = ['document_passed', 'interview', 'final_passed'];
130
151
 
131
152
  /**
132
153
  * Pick the recommended Career-OS route from the loaded context, so the AI is
133
154
  * nudged onto the expert procedure for what it's most likely about to do.
134
- * Returns a get_workflow_guide workflow_id, or null when there's no target job.
155
+ * Returns a get_workflow_guide workflow_id for job-centered tasks, or null when
156
+ * there's no target job. Non-job tasks such as write_career_description are
157
+ * selected from the user's explicit request via get_workflow_guide.
135
158
  */
136
159
  function pickRoute(ctx: ReturnType<typeof getApplicationContext>): string | null {
137
160
  if (!ctx.job) return null;
@@ -190,9 +213,9 @@ export const TOOLS: ToolDef[] = [
190
213
  /* --------------------------------------------------------- inbox (파일 인입) */
191
214
  {
192
215
  name: 'open_inbox',
193
- title: '이력서 인입 폴더 열기',
216
+ title: '문서 인입 폴더 열기',
194
217
  description:
195
- 'CLI 클라이언트는 파일 첨부가 안 되므로, 사용자가 기존 이력서·경력기술서 파일(hwp/hwpx/docx/pdf 등)을 끌어다 넣을 수 있는 로컬 인입 폴더(~/.careermate/inbox)를 보장하고 파일 탐색기로 엽니다. 온보딩에서 "기존에 작성해 둔 이력서 파일이 있다"고 사용자가 답했을 때만 호출하세요 — 묻지도 않고 폴더를 자동으로 열지 마세요. 파일 경로를 직접 알려주는 사용자에게는 이 도구 대신 read_document를 쓰세요. 호출하면 폴더 경로와 현재 폴더에 들어 있는 파일 목록을 돌려줍니다. 사용자가 파일을 다 넣었다고 하면 read_inbox로 본문을 읽으세요.',
218
+ 'CLI 클라이언트는 파일 첨부가 안 되므로, 사용자가 기존 이력서·경력기술서·포트폴리오·자기소개서 파일(hwp/hwpx/docx/pdf 등)을 끌어다 넣을 수 있는 로컬 인입 폴더(~/.careermate/inbox)를 보장하고 파일 탐색기로 엽니다. 온보딩에서 "기존에 작성해 둔 문서 파일이 있다"고 사용자가 답했을 때만 호출하세요 — 묻지도 않고 폴더를 자동으로 열지 마세요. 파일 경로를 직접 알려주는 사용자에게는 이 도구 대신 read_document를 쓰세요. 호출하면 폴더 경로와 현재 폴더에 들어 있는 파일 목록을 돌려줍니다. 사용자가 파일을 다 넣었다고 하면 read_inbox로 본문을 읽으세요.',
196
219
  inputSchema: {},
197
220
  handler: () => {
198
221
  const dir = getInboxDir();
@@ -200,7 +223,7 @@ export const TOOLS: ToolDef[] = [
200
223
  const files = listInboxFiles(dir);
201
224
  const list = files.length ? files.map((f) => `- ${f.name}`).join('\n') : '(아직 비어 있음)';
202
225
  return ok(
203
- `이력서 인입 폴더를 열었습니다:\n${dir}\n\n여기에 이력서·경력기술서 파일을 넣고 "다 넣었어"라고 알려주세요. (hwp/hwpx/docx는 자동으로 텍스트를 추출하고, pdf·이미지는 제가 직접 읽습니다.)\n\n현재 파일:\n${list}`,
226
+ `문서 인입 폴더를 열었습니다:\n${dir}\n\n여기에 이력서·경력기술서·포트폴리오·기존 자기소개서 파일을 넣고 "다 넣었어"라고 알려주세요. (hwp·hwpx·docx·pdf는 자동으로 텍스트를 추출하고, 텍스트 레이어가 없는 이미지·스캔 PDF만 제가 직접 읽습니다.)\n\n현재 파일:\n${list}`,
204
227
  { dir, files },
205
228
  );
206
229
  },
@@ -209,7 +232,7 @@ export const TOOLS: ToolDef[] = [
209
232
  name: 'read_inbox',
210
233
  title: '인입 폴더 파일 읽기',
211
234
  description:
212
- '인입 폴더(~/.careermate/inbox)에 사용자가 넣어 둔 문서들의 본문을 한 번에 읽어 돌려줍니다. open_inbox로 폴더를 안내한 뒤 사용자가 "다 넣었어"라고 하면 호출하세요. docx/hwp/hwpx/텍스트는 텍스트를 추출하고, pdf·이미지는 추출하지 않고 파일 경로를 돌려주니 AI 클라이언트의 파일 읽기 기능으로 그 경로를 직접 열어 읽으세요. 읽은 내용을 구조화해 save_profile·add_resume 등 알맞은 도구로 저장하세요(파일 내용을 새 파일로 다시 쓰지 마세요). filename을 주면 해당 파일만 읽습니다.',
235
+ '인입 폴더(~/.careermate/inbox)에 사용자가 넣어 둔 문서들의 본문을 한 번에 읽어 돌려줍니다. open_inbox로 폴더를 안내한 뒤 사용자가 "다 넣었어"라고 하면 호출하세요. docx·hwp·hwpx·pdf·텍스트(.txt/.md)에서 본문 텍스트를 추출해 돌려줍니다(read_document와 동일한 추출 스택). 단 텍스트 레이어가 없는 이미지·스캔 PDF는 추출되지 않아(unsupported=true) 파일 경로만 돌려주니, 그 경로는 AI 클라이언트의 파일 읽기 기능으로 직접 열어 읽으세요. 읽은 내용을 구조화해 프로필은 save_profile, 이력서·경력기술서·포트폴리오는 add_resume, 기존 자기소개서는 save_cover_letter_version으로 저장하세요(파일 내용을 새 파일로 다시 쓰지 마세요). filename을 주면 해당 파일만 읽습니다.',
213
236
  inputSchema: {
214
237
  filename: z.string().optional().describe('특정 파일만 읽을 때 파일명(대소문자 무시). 생략하면 폴더 안 모든 파일을 읽습니다.'),
215
238
  max_chars: z.number().optional().describe('파일별 반환 텍스트 최대 길이(초과 시 잘라냄). 기본 20000자'),
@@ -252,7 +275,7 @@ export const TOOLS: ToolDef[] = [
252
275
  )
253
276
  .join('\n');
254
277
  return ok(
255
- `인입 폴더 파일 ${files.length}건을 읽었습니다:\n${summary}\n\n추출된 내용을 구조화해 save_profile·add_resume 등으로 저장하세요.`,
278
+ `인입 폴더 파일 ${files.length}건을 읽었습니다:\n${summary}\n\n추출된 내용을 구조화해 프로필은 save_profile, 이력서·경력기술서·포트폴리오는 add_resume, 기존 자기소개서는 save_cover_letter_version으로 저장하세요.`,
256
279
  { dir, files },
257
280
  );
258
281
  },
@@ -325,7 +348,7 @@ export const TOOLS: ToolDef[] = [
325
348
  name: 'add_resume',
326
349
  title: '이력서/경력기술서 추가',
327
350
  description:
328
- '이력서, 경력기술서, 포트폴리오 등 문서를 저장합니다. content에는 사용자가 업로드/붙여넣은 텍스트(또는 정리한 Markdown)를 넣으세요. kind 기본값은 resume입니다. is_primary=true로 대표 문서를 지정하면 get_application_context의 primary_resume로 노출됩니다.',
351
+ '이력서, 경력기술서, 포트폴리오 등 문서를 저장합니다. content에는 사용자가 업로드/붙여넣은 텍스트 또는 AI가 전문가 절차로 정리한 Markdown을 넣으세요. 경력기술서를 작성해 저장할 때는 kind=career_description, source=ai를 사용합니다. ⚠️ content에는 이 문서 한 종류의 본문만 넣고, 자기소개서 등 다른 종류 문서를 같은 content에 이어붙이지 마세요 — 자기소개서는 save_cover_letter_version으로 따로 저장합니다(한 번에 둘 다 요청받았어도 한 저장 호출에 두 문서를 합치지 않습니다). kind 기본값은 resume입니다. is_primary=true로 대표 문서를 지정하면 get_application_context의 primary_resume로 노출됩니다.',
329
352
  // Reuse the shared (length-bounded, whitespace-rejecting) Document schema so
330
353
  // the MCP ingestion path — the primary route for résumé text — inherits the
331
354
  // same caps as the web API. kind stays optional here (defaults to resume).
@@ -349,6 +372,24 @@ export const TOOLS: ToolDef[] = [
349
372
  return ok(`문서 ${docs.length}건`, docs);
350
373
  },
351
374
  },
375
+ {
376
+ name: 'delete_resume',
377
+ title: '이력서/문서 삭제',
378
+ description:
379
+ '저장된 이력서·경력기술서·포트폴리오 등 문서를 삭제합니다. 사용자가 명확히 삭제를 요청했을 때만 사용하세요. 먼저 get_resumes로 목록을 확인하고 어떤 문서인지 사용자와 확인한 뒤 호출합니다. confirm에는 DELETE를 넣으세요.',
380
+ inputSchema: {
381
+ document_id: z.string(),
382
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
383
+ },
384
+ handler: (args) => {
385
+ if (args?.confirm !== DELETE_CONFIRMATION) return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
386
+ const doc = documentRepo.get(args.document_id);
387
+ if (!doc) return fail('문서를 찾을 수 없습니다.');
388
+ if (!documentRepo.remove(doc.id)) return fail('문서를 삭제하지 못했습니다.');
389
+ const user_message = `'${doc.title}' 문서를 삭제했어요.`;
390
+ return ok(user_message, { deleted: true, document_id: doc.id, user_message });
391
+ },
392
+ },
352
393
 
353
394
  /* ----------------------------------------------------------- experiences */
354
395
  {
@@ -424,6 +465,60 @@ export const TOOLS: ToolDef[] = [
424
465
  return ok(`기술 ${list.length}건`, list);
425
466
  },
426
467
  },
468
+ {
469
+ name: 'delete_experience',
470
+ title: '경력 항목 삭제',
471
+ description:
472
+ '구조화된 경력(직장) 항목 하나를 삭제합니다. 잘못 추출/입력된 경력을 지울 때 사용하세요. 먼저 get_experiences로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_experience로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
473
+ inputSchema: {
474
+ experience_id: z.string(),
475
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
476
+ },
477
+ handler: (args) => {
478
+ if (args?.confirm !== DELETE_CONFIRMATION) return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
479
+ const exp = experienceRepo.get(args.experience_id);
480
+ if (!exp) return fail('해당 경력 항목을 찾을 수 없습니다.');
481
+ if (!experienceRepo.remove(exp.id)) return fail('경력 항목을 삭제하지 못했습니다.');
482
+ const user_message = `'${exp.company}' 경력을 삭제했어요.`;
483
+ return ok(user_message, { deleted: true, experience_id: exp.id, user_message });
484
+ },
485
+ },
486
+ {
487
+ name: 'delete_project',
488
+ title: '프로젝트 항목 삭제',
489
+ description:
490
+ '구조화된 프로젝트 항목 하나를 삭제합니다. 잘못 추출/입력된 프로젝트를 지울 때 사용하세요. 먼저 get_projects로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_project로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
491
+ inputSchema: {
492
+ project_id: z.string(),
493
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
494
+ },
495
+ handler: (args) => {
496
+ if (args?.confirm !== DELETE_CONFIRMATION) return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
497
+ const proj = projectRepo.get(args.project_id);
498
+ if (!proj) return fail('해당 프로젝트 항목을 찾을 수 없습니다.');
499
+ if (!projectRepo.remove(proj.id)) return fail('프로젝트 항목을 삭제하지 못했습니다.');
500
+ const user_message = `'${proj.name}' 프로젝트를 삭제했어요.`;
501
+ return ok(user_message, { deleted: true, project_id: proj.id, user_message });
502
+ },
503
+ },
504
+ {
505
+ name: 'delete_skill',
506
+ title: '기술스택 항목 삭제',
507
+ description:
508
+ '구조화된 기술스택 항목 하나를 삭제합니다. 잘못 추출/입력된 기술을 지울 때 사용하세요. 먼저 get_skills로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_skill로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
509
+ inputSchema: {
510
+ skill_id: z.string(),
511
+ confirm: z.string().describe('삭제 확인값. 정확히 DELETE를 넣어야 삭제됩니다.'),
512
+ },
513
+ handler: (args) => {
514
+ if (args?.confirm !== DELETE_CONFIRMATION) return fail('삭제하려면 confirm에 DELETE를 넣어 다시 호출하세요.');
515
+ const skill = skillRepo.get(args.skill_id);
516
+ if (!skill) return fail('해당 기술 항목을 찾을 수 없습니다.');
517
+ if (!skillRepo.remove(skill.id)) return fail('기술 항목을 삭제하지 못했습니다.');
518
+ const user_message = `'${skill.name}' 기술을 삭제했어요.`;
519
+ return ok(user_message, { deleted: true, skill_id: skill.id, user_message });
520
+ },
521
+ },
427
522
 
428
523
  /* ---------------------------------------------------------- cover letters */
429
524
  {
@@ -441,7 +536,7 @@ export const TOOLS: ToolDef[] = [
441
536
  name: 'save_cover_letter_version',
442
537
  title: '자기소개서 버전 저장',
443
538
  description:
444
- '자기소개서의 새 버전을 저장합니다. CareerMate 자소서 작성 워크플로우의 핵심 저장 단계입니다. cover_letter_id를 주면 기존 자소서에 새 버전을 추가하고, 없으면 새 자소서를 만들어 v1로 저장합니다(이때 title 권장). note에 이 버전의 변경 요약(예: "지원동기 보강")을 남기면 사용자가 대시보드에서 버전 히스토리를 이해하기 쉽습니다. job_id로 공고에 연결하면 지원 항목과 자동 연결됩니다. 저장 직전 자동으로 "근거 없는 수치" 점검을 돌립니다 — 본문의 정량 수치가 저장된 경력·이력서·프로젝트에 근거가 없으면(환각 의심) 저장이 막힙니다. 그때는 원본의 실제 수치로 고치거나, 의도한 값이면 force:true로 다시 저장하세요.',
539
+ '자기소개서의 새 버전을 저장합니다. CareerMate 자소서 작성 워크플로우의 핵심 저장 단계입니다. ⚠️ content에는 자기소개서 본문만 넣고, 경력기술서 등 다른 종류 문서를 이어붙이지 마세요(경력기술서는 add_resume(kind=career_description)로 따로 저장). cover_letter_id를 주면 기존 자소서에 새 버전을 추가하고, 없으면 새 자소서를 만들어 v1로 저장합니다(이때 title 권장). note에 이 버전의 변경 요약(예: "지원동기 보강")을 남기면 사용자가 대시보드에서 버전 히스토리를 이해하기 쉽습니다. job_id로 공고에 연결하면 지원 항목과 자동 연결됩니다. 저장 직전 자동으로 "근거 없는 수치" 점검을 돌립니다 — 본문의 정량 수치가 저장된 경력·이력서·프로젝트에 근거가 없으면(환각 의심) 저장이 막힙니다. 그때는 원본의 실제 수치로 고치거나, 의도한 값이면 force:true로 다시 저장하세요.',
445
540
  inputSchema: CoverLetterVersionInputSchema.shape,
446
541
  handler: (args) => {
447
542
  try {
@@ -513,7 +608,7 @@ export const TOOLS: ToolDef[] = [
513
608
  {
514
609
  name: 'get_job_posting',
515
610
  title: '채용공고 상세 조회',
516
- description: '공고 1건의 상세를 조회합니다. 공고 정보와 함께 적합도 분석, 지원 상태, 연결된 자기소개서, 면접 준비 자료를 한 번에 돌려줍니다.',
611
+ description: '공고 1건의 상세를 조회합니다. 공고 정보와 함께 적합도 분석, 지원 상태, 자기소개서, 면접 준비 자료를 한 번에 돌려줍니다.',
517
612
  inputSchema: { job_id: z.string() },
518
613
  readOnly: true,
519
614
  handler: (args) => {
@@ -586,13 +681,48 @@ export const TOOLS: ToolDef[] = [
586
681
  // 여기에 추천 경로를 실어 전문가 절차로 유도한다. 스키마 변경 없이 반환 JSON만 확장.
587
682
  const recommended_route = pickRoute(ctx);
588
683
  const route = recommended_route ? getRoute(recommended_route) : undefined;
684
+ // 오늘 날짜 주입: LLM은 현재 날짜를 신뢰성 있게 모른다.
685
+ const today = new Date().toISOString().slice(0, 10);
686
+ // 재직기간/연차는 LLM이 산술하면 회귀하므로(검증됨) 서버가 결정적으로 계산해 제공한다.
687
+ // 재직중 경력의 확정 연차 = updated_at(정보 입력·확인 시점) − start_date — today가 아님(아직 재직
688
+ // 중인지 미확인이라 today로 계산하면 과대 산정·명시 연차와 충돌). 종료 경력 = end_date − start_date.
689
+ // data_age_months = 재직중 정보가 얼마나 오래됐나(크면 '현재도 재직 중인지 확인 필요').
690
+ const ym = (s: string | null | undefined): number | null => {
691
+ const m = s ? /^(\d{4})-(\d{2})/.exec(s) : null;
692
+ return m ? Number(m[1]) * 12 + Number(m[2]) : null;
693
+ };
694
+ const monthsBetween = (a: string | null | undefined, b: string | null | undefined): number | null => {
695
+ const x = ym(a), y = ym(b);
696
+ return x != null && y != null ? y - x : null;
697
+ };
698
+ const tenure = ctx.experiences
699
+ .map((e) => {
700
+ if (!e.start_date) return null;
701
+ const asOf = e.is_current ? (e.updated_at ?? today) : e.end_date;
702
+ const months = monthsBetween(e.start_date, asOf);
703
+ if (months == null || months < 0) return null;
704
+ return {
705
+ company: e.company,
706
+ is_current: e.is_current,
707
+ start_date: e.start_date,
708
+ as_of: (asOf ?? '').slice(0, 7), // 재직중=updated_at(정보 확인 시점) / 종료=end_date
709
+ months,
710
+ years: Math.round((months / 12) * 10) / 10,
711
+ // 음수 방지(미래 updated_at·시계 오차) — 0 미만은 0으로 클램프
712
+ data_age_months: e.is_current ? Math.max(0, monthsBetween(e.updated_at, today) ?? 0) : null,
713
+ };
714
+ })
715
+ .filter((t): t is NonNullable<typeof t> => t != null);
589
716
  const enriched = {
590
717
  ...ctx,
718
+ today,
719
+ tenure,
591
720
  recommended_route,
592
721
  verifier_sequence: route?.verifierSequence ?? [],
593
722
  next_tool: recommended_route ? 'get_workflow_guide' : null,
594
723
  };
595
724
  const summary = [
725
+ `오늘 날짜: ${today}. 경력 연차·재직기간은 직접 계산하지 말고 제공된 tenure[](서버 계산: 재직중=정보확인일 기준 연차, data_age_months 크면 현재 재직 확인 필요)를 그대로 쓰세요.`,
596
726
  `프로필: ${ctx.profile?.name ?? '미입력'}`,
597
727
  `이력서 ${ctx.resumes.length} · 경력 ${ctx.experiences.length} · 프로젝트 ${ctx.projects.length} · 스킬 ${ctx.skills.length} · 자소서 ${ctx.cover_letters.length}`,
598
728
  ctx.job ? `대상 공고: ${ctx.job.company} · ${ctx.job.position}` : '대상 공고 없음',
@@ -614,7 +744,7 @@ export const TOOLS: ToolDef[] = [
614
744
  try {
615
745
  const fit = saveFitAnalysis(args);
616
746
  const suggested_next_action = {
617
- prompt: '이 공고에 맞춘 자기소개서를 써드릴까요?',
747
+ prompt: '이 공고에 맞춘 자기소개서를 써드릴까요? (회사 지정 문항·양식·글자수가 있으면 함께 알려주시면 그에 맞춰 작성합니다)',
618
748
  options: [
619
749
  {
620
750
  label: '네, 이 공고에 맞춘 자기소개서를 써줘',
@@ -634,6 +764,7 @@ export const TOOLS: ToolDef[] = [
634
764
  '사용자에게 다음 선택지를 보여주세요:',
635
765
  '1. 네, 이 공고에 맞춘 자기소개서를 써줘',
636
766
  '2. 아니요, 분석만 저장해줘',
767
+ '(1번이면, 회사 지정 문항·양식·글자수가 있으면 알려달라고 요청하세요 — 한국 자소서는 문항형이 많습니다. 없으면 일반 양식으로 진행합니다.)',
637
768
  ].join('\n'),
638
769
  {
639
770
  ...fit,
@@ -656,10 +787,11 @@ export const TOOLS: ToolDef[] = [
656
787
  job_id: z.string(),
657
788
  status: z.enum(APPLICATION_STATUSES),
658
789
  note: z.string().optional(),
790
+ submission: ApplicationSubmissionSchema.optional(),
659
791
  },
660
792
  handler: (args) => {
661
793
  try {
662
- const res = updateApplicationStatus(args.job_id, args.status, args.note);
794
+ const res = updateApplicationStatus(args.job_id, args.status, args.note, args.submission);
663
795
  const user_message =
664
796
  `지원 상태를 '${APPLICATION_STATUS_LABELS[args.status as keyof typeof APPLICATION_STATUS_LABELS]}'(으)로 바꿨어요.` +
665
797
  (res.hint ? `\n\n${res.hint}` : '');
@@ -687,12 +819,19 @@ export const TOOLS: ToolDef[] = [
687
819
  name: 'save_interview_prep',
688
820
  title: '면접 준비 자료 저장',
689
821
  description:
690
- '예상 면접 질문, 꼬리 질문, STAR 답변 가이드, 1분 자기소개 초안 등 면접 준비 자료를 저장합니다. 보통 지원 상태가 서류 합격으로 바뀐 뒤, 해당 공고/직무/자소서를 근거로 당신(AI)이 생성한 자료를 저장하는 데 사용합니다. job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다.',
822
+ '예상 면접 질문, 꼬리 질문, STAR 답변 가이드, 1분 자기소개 초안 등 해당 공고 기준 면접 준비 자료를 저장합니다. 공고/직무/자기소개서를 근거로 당신(AI)이 생성한 자료를 저장하는 데 사용합니다. ' +
823
+ "질문은 두 갈래로 준비하세요 — 각 질문의 category를 'technical'(기술·직무 1차 면접) 또는 'behavioral'(인성·컬처핏·임원 2차 면접, 자기소개서·프로필 기반)로 지정합니다. 가능하면 두 갈래 모두 채워 한 번에 저장하세요(대시보드가 기술/인성·컬처핏 탭으로 나눠 보여줍니다). " +
824
+ 'job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다.',
691
825
  inputSchema: InterviewPrepInputSchema.shape,
692
826
  handler: (args) => {
693
827
  try {
694
828
  const prep = saveInterviewPrep(args);
695
- const user_message = `면접 준비 자료를 저장했어요. 예상 질문 ${prep.questions.length}개가 준비되어 있습니다.`;
829
+ const tech = prep.questions.filter((q) => (q.category ?? 'technical') === 'technical').length;
830
+ const behavioral = prep.questions.filter((q) => q.category === 'behavioral').length;
831
+ const user_message =
832
+ `면접 준비 자료를 저장했어요. 예상 질문 ${prep.questions.length}개` +
833
+ (behavioral > 0 ? ` (기술 ${tech}개 · 인성·컬처핏 ${behavioral}개)` : '') +
834
+ '가 준비되어 있습니다.';
696
835
  return ok(user_message, { ...prep, user_message });
697
836
  } catch (e) {
698
837
  return fail(e instanceof Error ? e.message : '저장 실패');
@@ -731,6 +870,68 @@ export const TOOLS: ToolDef[] = [
731
870
  });
732
871
  },
733
872
  },
873
+ {
874
+ name: 'export_resume',
875
+ title: '이력서/경력기술서 내보내기',
876
+ description:
877
+ '저장된 이력서·경력기술서 등 문서를 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 공고에 맞춰 정리한 이력서를 먼저 add_resume로 저장한 뒤, "이력서 파일로 받고 싶어"라고 할 때 사용하세요. document_id는 get_resumes로 확인할 수 있습니다. 대시보드에서 직접 다운로드할 수도 있습니다.',
878
+ inputSchema: {
879
+ document_id: z.string(),
880
+ format: z.enum(['md', 'html']).optional().describe('기본 md'),
881
+ },
882
+ handler: (args) => {
883
+ const doc = documentRepo.get(args.document_id);
884
+ if (!doc) return fail('문서를 찾을 수 없습니다.');
885
+ const profile = profileRepo.get();
886
+ const format = args.format ?? 'md';
887
+ const result = format === 'html' ? resumeToHtml(doc, profile) : resumeToMarkdown(doc, profile);
888
+ const dir = getExportsDir();
889
+ const filePath = path.join(dir, result.filename);
890
+ try {
891
+ fs.writeFileSync(filePath, result.content, 'utf8');
892
+ } catch (e) {
893
+ return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
894
+ }
895
+ return ok(`'${doc.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${filePath}`, {
896
+ path: filePath,
897
+ filename: result.filename,
898
+ content: result.content,
899
+ });
900
+ },
901
+ },
902
+ {
903
+ name: 'export_profile',
904
+ title: '프로필을 이력서로 내보내기',
905
+ description:
906
+ '저장된 프로필·경력·프로젝트·보유 기술을 한 장의 이력서 형식으로 모아 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 "내 프로필을 이력서로 뽑아줘"처럼 구조화된 데이터 전체를 이력서로 받고 싶을 때 사용하세요. 특정 공고 맞춤 이력서는 add_resume로 정리·저장한 뒤 export_resume를 쓰세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
907
+ inputSchema: {
908
+ format: z.enum(['md', 'html']).optional().describe('기본 md'),
909
+ },
910
+ handler: (args) => {
911
+ const profile = profileRepo.get();
912
+ if (!profile) return fail('프로필이 없습니다. 먼저 프로필을 저장하세요.');
913
+ const format = args.format ?? 'md';
914
+ const experiences = experienceRepo.list();
915
+ const projects = projectRepo.list();
916
+ const skills = skillRepo.list();
917
+ const result =
918
+ format === 'html'
919
+ ? profileToHtml(profile, experiences, projects, skills)
920
+ : profileToMarkdown(profile, experiences, projects, skills);
921
+ const dir = getExportsDir();
922
+ const filePath = path.join(dir, result.filename);
923
+ try {
924
+ fs.writeFileSync(filePath, result.content, 'utf8');
925
+ } catch (e) {
926
+ return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
927
+ }
928
+ return ok(`프로필을 이력서(${format.toUpperCase()})로 내보냈습니다.\n저장 위치: ${filePath}`, {
929
+ path: filePath,
930
+ filename: result.filename,
931
+ content: result.content,
932
+ });
933
+ },
934
+ },
734
935
 
735
936
  /* ----------------------------------------------------------- dashboard ui */
736
937
  {
@@ -780,10 +981,18 @@ export const TOOLS: ToolDef[] = [
780
981
  name: 'get_workflow_guide',
781
982
  title: '워크플로우 가이드',
782
983
  description:
783
- '커리어 작업(공고 분석·자소서·면접 준비 등)을 시작하기 직전에 호출하세요. 표준 워크플로우의 단계별 안내에 더해, 그 작업의 "전문가 실행 절차(EOP)"와 적용할 전문가 플레이북·저장 전 검증기 순서를 한 번에 돌려줍니다. 안내된 대로 get_playbook으로 도메인 지식을, 저장 직전 get_verifier로 검증 루브릭을 받아 적용하세요. workflow_id를 생략하면 전체 목록을 돌려줍니다.',
984
+ '커리어 작업(공고 분석·자소서·경력기술서·면접 준비 등)을 시작하기 직전에 호출하세요. 표준 워크플로우의 단계별 안내에 더해, 그 작업의 "전문가 실행 절차(EOP)"와 적용할 전문가 플레이북·저장 전 검증기 순서를 한 번에 돌려줍니다. 안내된 대로 get_playbook으로 도메인 지식을, 저장 직전 get_verifier로 검증 루브릭을 받아 적용하세요. workflow_id를 생략하면 전체 목록을 돌려줍니다.',
784
985
  inputSchema: {
785
986
  workflow_id: z
786
- .enum(['onboarding', 'analyze_job', 'write_cover_letter', 'manage_application_status', 'prepare_interview'])
987
+ .enum([
988
+ 'onboarding',
989
+ 'analyze_job',
990
+ 'write_cover_letter',
991
+ 'write_career_description',
992
+ 'manage_application_status',
993
+ 'prepare_interview',
994
+ 'build_personal_brand',
995
+ ])
787
996
  .optional(),
788
997
  },
789
998
  readOnly: true,
@@ -899,10 +1108,27 @@ export const TOOLS: ToolDef[] = [
899
1108
  name: 'get_writing_style_guide',
900
1109
  title: 'AI 티 안 나는 글쓰기 가이드',
901
1110
  description:
902
- '자기소개서·자기 PR·지원 메일 등 사람이 쓴 듯한 글을 작성하기 직전에 호출하세요. "AI가 쓴 티"가 나는 한국어 문장 습관(번역투, 클리셰, 기계적 병렬, 상투적 연결어, 균일한 문장 리듬 등)을 제거하는 작성 규칙과 저장 전 자가 점검 항목을 돌려줍니다. 사실·수치·고유명사는 그대로 두고 문장 결만 다듬도록 안내합니다. write_cover_letter / analyze_job 워크플로우에서 자기소개서를 쓸 때 반드시 함께 적용하세요.',
903
- inputSchema: {},
1111
+ '자기소개서·자기 PR·지원 메일 등 사람이 쓴 듯한 글을 작성하기 직전에 호출하세요. "AI가 쓴 티"가 나는 한국어 문장 습관(번역투, 클리셰, 기계적 병렬, 상투적 연결어, 균일한 문장 리듬 등)을 제거하는 작성 규칙과 저장 전 자가 점검 항목을 돌려줍니다. 사실·수치·고유명사는 그대로 두고 문장 결만 다듬도록 안내합니다. 경력기술서·이력서·포트폴리오를 쓸 때는 doc_type을 함께 주면 불릿·번호 성과 같은 구조를 산문으로 평탄화하지 않도록 구조 보존 보정 지침을 더해 돌려줍니다. write_cover_letter / write_career_description / analyze_job 워크플로우에서 글을 쓸 때 반드시 함께 적용하세요.',
1112
+ inputSchema: {
1113
+ doc_type: z
1114
+ .enum([
1115
+ 'cover_letter',
1116
+ 'self_pr',
1117
+ 'application_email',
1118
+ 'career_description',
1119
+ 'resume',
1120
+ 'portfolio',
1121
+ ])
1122
+ .optional()
1123
+ .describe(
1124
+ '작성할 문서 유형. career_description·resume·portfolio는 불릿·구조를 보존하는 보정을 더해 돌려줍니다. 생략하면 자기소개서 등 산문형 기준.',
1125
+ ),
1126
+ },
904
1127
  readOnly: true,
905
- handler: () => ok('AI 티 안 나는 글쓰기 가이드를 적용해 작성하세요.', { guide: HUMANIZE_WRITING_GUIDE }),
1128
+ handler: (args) =>
1129
+ ok('AI 티 안 나는 글쓰기 가이드를 적용해 작성하세요.', {
1130
+ guide: getWritingStyleGuide(args?.doc_type),
1131
+ }),
906
1132
  },
907
1133
 
908
1134
  /* ----------------------------------------------------------------- update */
@@ -914,7 +1140,7 @@ export const TOOLS: ToolDef[] = [
914
1140
  inputSchema: {},
915
1141
  readOnly: true,
916
1142
  handler: async () => {
917
- const u = await getUpdateStatusAsync();
1143
+ const u = await getUpdateStatusAsync(true); // "지금 확인" — 신선한 캐시 무시하고 재조회
918
1144
  if (u.latest === null) return ok('업데이트 확인에 실패했습니다(오프라인이거나 일시적 오류). 잠시 후 다시 시도하세요.', u);
919
1145
  const user_message = u.update_available
920
1146
  ? `새 버전 v${u.latest}이 나왔어요. 현재 버전은 v${u.current}입니다. 원하시면 지금 업데이트할 수 있습니다.`
@@ -193,17 +193,34 @@ export function getUpdateStatus(): UpdateStatus {
193
193
  }
194
194
 
195
195
  /**
196
- * 즉시(바운드) 업데이트 확인. 캐시가 만료면 레지스트리를 한 번 await하고 캐시를 갱신한다.
197
- * doctor·check_for_update처럼 "지금 확인" 의미가 분명한 일회성 호출에서 사용.
196
+ * 즉시(바운드) 업데이트 확인. doctor·check_for_update처럼 "지금 확인" 의미가 분명한
197
+ * 일회성 호출에서 사용.
198
+ *
199
+ * `force=true`("지금 확인")면 신선한 24h 캐시여도 무시하고 레지스트리를 새로 읽는다 —
200
+ * 캐시가 직전 릴리스(예: 0.0.3) 시점에 채워진 채 새 버전(0.0.4)이 나오면, 설치본보다
201
+ * 오래된 `latest`를 돌려주던 버그를 막는다. 강제 조회가 네트워크 오류로 실패하면 멀쩡한
202
+ * 기존 캐시를 null로 덮지 않고(비차단 신호 getUpdateStatus 보호) "확인 실패"로만 보고한다.
203
+ *
204
+ * `force=false`(기본, 만료 재확인)면 기존대로 캐시를 존중하고, 실패는 짧은 TTL의 null로
205
+ * 기록해 잦은 재시도를 막는다.
198
206
  */
199
- export async function getUpdateStatusAsync(): Promise<UpdateStatus> {
207
+ export async function getUpdateStatusAsync(force = false): Promise<UpdateStatus> {
200
208
  if (checkDisabled()) return buildStatus(null);
201
- let cache = readCache();
202
- if (cacheExpired(cache)) {
203
- cache = { checkedAt: Date.now(), latest: await fetchLatest() };
204
- writeCache(cache);
209
+ const cache = readCache();
210
+ if (!force && !cacheExpired(cache)) return buildStatus(cache);
211
+
212
+ const latest = await fetchLatest();
213
+ if (latest !== null) {
214
+ const fresh = { checkedAt: Date.now(), latest };
215
+ writeCache(fresh);
216
+ return buildStatus(fresh);
205
217
  }
206
- return buildStatus(cache);
218
+ // fetch 실패. 강제("지금 확인")는 정직하게 실패로 보고하되 멀쩡한 기존 캐시는 보존한다.
219
+ if (force) return buildStatus(null);
220
+ // 비강제(만료 재확인)는 기존처럼 null을 짧은 TTL로 기록해 잦은 재시도를 막는다.
221
+ const failed = { checkedAt: Date.now(), latest: null };
222
+ writeCache(failed);
223
+ return buildStatus(failed);
207
224
  }
208
225
 
209
226
  export interface UpdateResult {
@@ -288,13 +305,11 @@ export async function runSelfUpdate(): Promise<UpdateResult> {
288
305
  manual: manualCommand(),
289
306
  };
290
307
  }
291
- // 최신 버전: 신선한 캐시가 있으면 재사용하고, 없으면 한 번만 조회한다(레지스트리 중복 호출 방지).
292
- let cache = readCache();
293
- if (cacheExpired(cache)) {
294
- cache = { checkedAt: Date.now(), latest: await fetchLatest() };
295
- writeCache(cache);
296
- }
297
- const latest = cache?.latest ?? null;
308
+ // 최신 버전: "업데이트해줘"는 명시적 사용자 행동이라 신선한 캐시여도 새로 조회한다 —
309
+ // 직전 릴리스 시점에 채워진 캐시가 'already latest'로 오판해 실제 업데이트를 건너뛰지 않게.
310
+ // 실패(null)면 아래 가드가 안내로 빠지며, 멀쩡한 기존 캐시는 덮지 않는다.
311
+ const latest = await fetchLatest();
312
+ if (latest !== null) writeCache({ checkedAt: Date.now(), latest });
298
313
  // 최신 버전을 확인하지 못함(오프라인/레지스트리 오류): 무엇을 설치할지 모른 채 실행 중인
299
314
  // 설치를 덮어쓰면 반파 위험이 있으므로 시도하지 않고 안내한다.
300
315
  if (latest === null) {