careermate 0.0.8 → 0.0.10

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 (68) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +78 -81
  4. package/bin/careermate.mjs +11 -2
  5. package/dist/career-os/eop/cover-letter.md +10 -4
  6. package/dist/career-os/eop/interview-prep.md +1 -0
  7. package/dist/career-os/knowledge/cover-letter.md +4 -0
  8. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  9. package/dist/doctor.mjs +18138 -6866
  10. package/dist/mcp.mjs +14820 -4124
  11. package/dist/migrate.mjs +14436 -3712
  12. package/dist/pdf.worker.min.mjs +4 -4
  13. package/dist/public/app.js +10 -19
  14. package/dist/public/i18n/en/applications.js +19 -0
  15. package/dist/public/i18n/en/common.js +6 -0
  16. package/dist/public/i18n/en/documents.js +1 -0
  17. package/dist/public/i18n/en/home.js +8 -1
  18. package/dist/public/i18n/en/jobs.js +19 -0
  19. package/dist/public/i18n/en/nav.js +1 -0
  20. package/dist/public/i18n/en/settings.js +5 -2
  21. package/dist/public/i18n/ko/applications.js +19 -0
  22. package/dist/public/i18n/ko/common.js +5 -0
  23. package/dist/public/i18n/ko/documents.js +1 -0
  24. package/dist/public/i18n/ko/home.js +8 -1
  25. package/dist/public/i18n/ko/jobs.js +19 -0
  26. package/dist/public/i18n/ko/nav.js +1 -0
  27. package/dist/public/i18n/ko/settings.js +5 -2
  28. package/dist/public/lib.js +29 -0
  29. package/dist/public/pages/applications.js +86 -10
  30. package/dist/public/pages/documents.js +25 -12
  31. package/dist/public/pages/home.js +24 -4
  32. package/dist/public/pages/insights.js +156 -0
  33. package/dist/public/pages/interview.js +11 -4
  34. package/dist/public/pages/jobs.js +92 -4
  35. package/dist/public/pages/settings.js +2 -2
  36. package/dist/public/styles.css +16 -0
  37. package/dist/web.mjs +17079 -5651
  38. package/docs/FAQ.md +13 -3
  39. package/docs/LOCAL_FIRST.md +32 -0
  40. package/docs/MCP_TOOLS.md +22 -9
  41. package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
  42. package/docs/career-os/eop/cover-letter.md +10 -4
  43. package/docs/career-os/eop/interview-prep.md +1 -0
  44. package/docs/career-os/knowledge/cover-letter.md +4 -0
  45. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  46. package/package.json +9 -5
  47. package/packages/core/src/data-health.ts +161 -0
  48. package/packages/core/src/fit-explain.ts +63 -0
  49. package/packages/core/src/index.ts +4 -0
  50. package/packages/core/src/jobsearch.ts +99 -54
  51. package/packages/core/src/offers.ts +41 -0
  52. package/packages/core/src/reuse-insights.ts +126 -0
  53. package/packages/core/src/services.ts +5 -1
  54. package/packages/core/src/summary.ts +245 -19
  55. package/packages/db/src/connection.ts +102 -14
  56. package/packages/db/src/index.ts +3 -0
  57. package/packages/db/src/interview-retrospectives.ts +149 -0
  58. package/packages/db/src/provenance.ts +125 -0
  59. package/packages/db/src/repositories.ts +140 -8
  60. package/packages/db/src/schema.ts +140 -8
  61. package/packages/mcp-tools/src/result.ts +9 -0
  62. package/packages/mcp-tools/src/tools.ts +143 -20
  63. package/packages/parsers/src/documents.ts +7 -1
  64. package/packages/prompts/src/humanize.ts +4 -0
  65. package/packages/prompts/src/system.ts +21 -64
  66. package/packages/shared/src/schemas.ts +134 -0
  67. package/scripts/init.ts +2 -0
  68. package/tsconfig.json +9 -10
@@ -2,44 +2,36 @@
2
2
  * Job search import — pull postings from key-free public job-board endpoints by
3
3
  * keyword, so the AI can offer real listings the user can save into CareerMate.
4
4
  *
5
- * Scope & boundaries (deliberate):
6
- * - INBOUND only. We fetch publicly visible postings; we send only the search
7
- * keyword, never the user's stored data. (Privacy = no user data leaves; this
8
- * does not violate it.)
9
- * - No WAF bypass, no auth keys. These providers' own frontends call these JSON
10
- * endpoints unauthenticated; a plain fetch suffices. We do NOT bundle a
11
- * scraper/anti-bot evasion (that's the legally dangerous part — see
12
- * .claude/work/research/research_0004). Sites requiring keys (Saramin oapi,
13
- * 고용24) or that forbid it in robots.txt (JobKorea search) are excluded here.
14
- * - Read-only w.r.t. the DB. This returns candidates; the AI shows them and the
15
- * user picks which to save via save_job_posting (no auto-dumping into the DB).
16
- *
17
- * Providers are unofficial public endpoints — if a response shape changes, only
18
- * the affected provider's parser needs updating (others keep working).
5
+ * CareerMate is local-first: only the search keyword leaves the machine. Stored
6
+ * profile, résumé, application, and document data are never sent to providers.
7
+ * Provider endpoints are unofficial and therefore validated at runtime; a shape
8
+ * change degrades only that provider instead of silently returning corrupt rows.
19
9
  */
20
10
 
11
+ import { z } from 'zod';
12
+ import { APP_VERSION } from '@careermate/shared';
13
+
21
14
  export interface JobSearchResult {
22
- /** Provider id, e.g. 'wanted' | 'jumpit'. */
23
15
  source: string;
24
16
  company: string;
25
17
  position: string;
26
- /** Canonical posting URL the user can open. */
27
18
  url: string;
28
19
  location?: string;
29
- /** Required experience, human-readable (e.g. '신입', '5~20년'). */
30
20
  experience?: string;
31
- /** Tech stack / tags when the provider exposes them. */
32
21
  keywords?: string[];
33
- /** Application deadline as YYYY-MM-DD when known. */
34
22
  deadline?: string;
35
- /** Provider-native id (for dedup / detail fetch). */
36
23
  external_id: string;
37
24
  }
38
25
 
26
+ export type ProviderStatus = 'healthy' | 'degraded' | 'unavailable';
27
+
39
28
  export interface ProviderOutcome {
40
29
  source: string;
30
+ label: string;
41
31
  ok: boolean;
32
+ status: ProviderStatus;
42
33
  count: number;
34
+ checked_at: string;
43
35
  error?: string;
44
36
  }
45
37
 
@@ -50,25 +42,20 @@ export interface JobSearchResponse {
50
42
  }
51
43
 
52
44
  export interface JobSearchOptions {
53
- /** Which providers to query. Defaults to all registered. */
54
45
  sources?: string[];
55
- /** Max results per provider (1–50). Default 10. */
56
46
  limit?: number;
57
- /** Per-request timeout in ms. Default 10000. */
58
47
  timeoutMs?: number;
59
48
  }
60
49
 
61
- const UA =
62
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
50
+ const UA = `careermate/${APP_VERSION} (+https://careermate.life)`;
63
51
 
64
- /** Slice an ISO timestamp / date string to YYYY-MM-DD; undefined if not a date. */
65
52
  function toYmd(v: unknown): string | undefined {
66
53
  if (typeof v !== 'string' || v.length < 10) return undefined;
67
54
  const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
68
55
  return m ? m[1] : undefined;
69
56
  }
70
57
 
71
- async function getJson(url: string, timeoutMs: number): Promise<any> {
58
+ async function getJson(url: string, timeoutMs: number): Promise<unknown> {
72
59
  const ctrl = new AbortController();
73
60
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
74
61
  try {
@@ -77,6 +64,8 @@ async function getJson(url: string, timeoutMs: number): Promise<any> {
77
64
  signal: ctrl.signal,
78
65
  });
79
66
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
67
+ const type = res.headers.get('content-type') ?? '';
68
+ if (!type.includes('json')) throw new Error(`unexpected content-type: ${type || 'unknown'}`);
80
69
  return await res.json();
81
70
  } finally {
82
71
  clearTimeout(timer);
@@ -89,7 +78,41 @@ interface Provider {
89
78
  search(query: string, limit: number, timeoutMs: number): Promise<JobSearchResult[]>;
90
79
  }
91
80
 
92
- /** Wanted (wanted.co.kr) — public v4 jobs JSON, no auth. IT/startup roles. */
81
+ const wantedSchema = z.object({
82
+ data: z.array(
83
+ z.object({
84
+ id: z.union([z.string(), z.number()]),
85
+ position: z.string().min(1),
86
+ company: z.object({ name: z.string().min(1) }),
87
+ annual_from: z.number().nullable().optional(),
88
+ annual_to: z.number().nullable().optional(),
89
+ due_time: z.string().nullable().optional(),
90
+ address: z
91
+ .object({ location: z.string().optional(), full_location: z.string().optional() })
92
+ .nullable()
93
+ .optional(),
94
+ }).passthrough(),
95
+ ),
96
+ }).passthrough();
97
+
98
+ const jumpitSchema = z.object({
99
+ result: z.object({
100
+ positions: z.array(
101
+ z.object({
102
+ id: z.union([z.string(), z.number()]),
103
+ companyName: z.string().min(1),
104
+ title: z.string().min(1),
105
+ locations: z.array(z.string()).optional(),
106
+ newcomer: z.boolean().optional(),
107
+ minCareer: z.number().nullable().optional(),
108
+ maxCareer: z.number().nullable().optional(),
109
+ techStacks: z.array(z.string()).optional(),
110
+ closedAt: z.string().nullable().optional(),
111
+ }).passthrough(),
112
+ ),
113
+ }).passthrough(),
114
+ }).passthrough();
115
+
93
116
  const wanted: Provider = {
94
117
  id: 'wanted',
95
118
  label: '원티드',
@@ -97,81 +120,103 @@ const wanted: Provider = {
97
120
  const url =
98
121
  `https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1` +
99
122
  `&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
100
- const j = await getJson(url, timeoutMs);
101
- const rows: any[] = Array.isArray(j?.data) ? j.data : [];
102
- return rows.map((r) => {
123
+ const j = wantedSchema.parse(await getJson(url, timeoutMs));
124
+ return j.data.map((r) => {
103
125
  const annual =
104
126
  r.annual_from || r.annual_to
105
127
  ? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
106
128
  : undefined;
107
129
  return {
108
130
  source: 'wanted',
109
- company: r.company?.name ?? '',
110
- position: r.position ?? '',
131
+ company: r.company.name,
132
+ position: r.position,
111
133
  url: `https://www.wanted.co.kr/wd/${r.id}`,
112
- location: r.address?.location ?? r.address?.full_location ?? undefined,
134
+ location: r.address?.location ?? r.address?.full_location,
113
135
  experience: annual,
114
136
  deadline: toYmd(r.due_time),
115
137
  external_id: String(r.id),
116
- } as JobSearchResult;
138
+ };
117
139
  });
118
140
  },
119
141
  };
120
142
 
121
- /** Jumpit (jumpit.co.kr) — public positions JSON, no auth. Dev roles, tech stacks. */
122
143
  const jumpit: Provider = {
123
144
  id: 'jumpit',
124
145
  label: '점핏',
125
146
  async search(query, limit, timeoutMs) {
126
147
  const url =
127
148
  `https://api.jumpit.co.kr/api/positions?sort=rsp_rate&page=1&keyword=${encodeURIComponent(query)}`;
128
- const j = await getJson(url, timeoutMs);
129
- const rows: any[] = Array.isArray(j?.result?.positions) ? j.result.positions : [];
130
- return rows.slice(0, limit).map((p) => ({
149
+ const j = jumpitSchema.parse(await getJson(url, timeoutMs));
150
+ return j.result.positions.slice(0, limit).map((p) => ({
131
151
  source: 'jumpit',
132
- company: p.companyName ?? '',
133
- position: p.title ?? '',
152
+ company: p.companyName,
153
+ position: p.title,
134
154
  url: `https://www.jumpit.co.kr/position/${p.id}`,
135
- location: Array.isArray(p.locations) ? p.locations[0] : undefined,
155
+ location: p.locations?.[0],
136
156
  experience: p.newcomer
137
157
  ? '신입'
138
158
  : p.minCareer != null
139
159
  ? `${p.minCareer}~${p.maxCareer ?? p.minCareer}년`
140
160
  : undefined,
141
- keywords: Array.isArray(p.techStacks) ? p.techStacks : undefined,
161
+ keywords: p.techStacks,
142
162
  deadline: toYmd(p.closedAt),
143
163
  external_id: String(p.id),
144
- } as JobSearchResult));
164
+ }));
145
165
  },
146
166
  };
147
167
 
148
168
  const PROVIDERS: Provider[] = [wanted, jumpit];
149
-
150
- /** Registered provider ids (key-free, no WAF bypass). */
151
169
  export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
152
170
 
153
- /**
154
- * Search key-free public job boards by keyword. Each provider runs independently;
155
- * one failing (network/timeout/shape change) does not sink the others. Results
156
- * are returned for the AI to present — saving is a separate explicit step.
157
- */
171
+ function providerError(e: unknown): { status: ProviderStatus; error: string } {
172
+ if (e instanceof z.ZodError) {
173
+ const first = e.issues[0];
174
+ return {
175
+ status: 'degraded',
176
+ error: `provider response changed${first ? ` (${first.path.join('.') || 'root'})` : ''}`,
177
+ };
178
+ }
179
+ const message = e instanceof Error ? e.message : String(e);
180
+ return { status: 'unavailable', error: message };
181
+ }
182
+
158
183
  export async function searchJobs(query: string, opts: JobSearchOptions = {}): Promise<JobSearchResponse> {
159
184
  const q = (query ?? '').trim();
160
185
  if (!q) return { query: '', results: [], providers: [] };
161
186
  const limit = Math.min(50, Math.max(1, opts.limit ?? 10));
162
- const timeoutMs = opts.timeoutMs ?? 10000;
187
+ const timeoutMs = Math.min(30_000, Math.max(1_000, opts.timeoutMs ?? 10_000));
163
188
  const chosen = opts.sources?.length
164
189
  ? PROVIDERS.filter((p) => opts.sources!.includes(p.id))
165
190
  : PROVIDERS;
166
191
 
167
192
  const settled = await Promise.all(
168
193
  chosen.map(async (p): Promise<{ outcome: ProviderOutcome; results: JobSearchResult[] }> => {
194
+ const checked_at = new Date().toISOString();
169
195
  try {
170
196
  const results = await p.search(q, limit, timeoutMs);
171
- return { outcome: { source: p.id, ok: true, count: results.length }, results };
197
+ return {
198
+ outcome: {
199
+ source: p.id,
200
+ label: p.label,
201
+ ok: true,
202
+ status: 'healthy',
203
+ count: results.length,
204
+ checked_at,
205
+ },
206
+ results,
207
+ };
172
208
  } catch (e) {
209
+ const failure = providerError(e);
173
210
  return {
174
- outcome: { source: p.id, ok: false, count: 0, error: e instanceof Error ? e.message : String(e) },
211
+ outcome: {
212
+ source: p.id,
213
+ label: p.label,
214
+ ok: false,
215
+ status: failure.status,
216
+ count: 0,
217
+ checked_at,
218
+ error: failure.error,
219
+ },
175
220
  results: [],
176
221
  };
177
222
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Offer comparison use-cases. CareerMate stores offers and a deterministic annual
3
+ * cash estimate (base+bonus+welfare); the connected AI writes the score/verdict at
4
+ * save time (no LLM here). compareOffers returns all offers joined with their job,
5
+ * pre-sorted by the AI's score — the dashboard renders that, and the head-to-head
6
+ * "which is better" reasoning happens in the AI chat via this same data.
7
+ */
8
+ import { offerRepo, jobRepo } from '@careermate/db';
9
+ import type { OfferInput, OfferRecord, JobRecord } from '@careermate/shared';
10
+
11
+ export interface OfferWithJob extends OfferRecord {
12
+ company: string;
13
+ position: string;
14
+ }
15
+
16
+ function withJob(offer: OfferRecord, job: JobRecord | null): OfferWithJob {
17
+ return { ...offer, company: job?.company ?? '—', position: job?.position ?? '—' };
18
+ }
19
+
20
+ /** Save/update the offer for a job (one per job). The job must exist first. */
21
+ export function saveOffer(input: OfferInput): OfferWithJob {
22
+ const job = jobRepo.get(input.job_id);
23
+ if (!job) throw new Error('공고를 찾을 수 없습니다. 먼저 save_job_posting으로 공고를 저장하세요.');
24
+ return withJob(offerRepo.save(input), job);
25
+ }
26
+
27
+ /** The offer for one job (or null). */
28
+ export function getOffer(jobId: string): OfferWithJob | null {
29
+ const offer = offerRepo.getByJob(jobId);
30
+ if (!offer) return null;
31
+ return withJob(offer, jobRepo.get(jobId));
32
+ }
33
+
34
+ /**
35
+ * All offers, joined with their job, sorted by the AI's score (desc, null last).
36
+ * `total_comp_annual_est` on each is the server's deterministic sum — the AI does
37
+ * the qualitative comparison from this structured data.
38
+ */
39
+ export function compareOffers(): OfferWithJob[] {
40
+ return offerRepo.list().map((o) => withJob(o, jobRepo.get(o.job_id)));
41
+ }
@@ -0,0 +1,126 @@
1
+ import { coverLetterRepo, experienceRepo, projectRepo, jobRepo } from '@careermate/db';
2
+
3
+ export interface ReuseReference {
4
+ kind: 'experience' | 'project';
5
+ entity_id: string;
6
+ label: string;
7
+ cover_letters: Array<{ id: string; title: string; job_id: string | null; company: string | null; position: string | null }>;
8
+ reuse_count: number;
9
+ }
10
+
11
+ export interface DiffLine {
12
+ type: 'equal' | 'add' | 'remove';
13
+ text: string;
14
+ }
15
+
16
+ function containsNormalized(content: string, needle: string): boolean {
17
+ const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
18
+ const n = normalize(needle);
19
+ return n.length >= 2 && normalize(content).includes(n);
20
+ }
21
+
22
+ export function getExperienceReuseMap(): ReuseReference[] {
23
+ const letters = coverLetterRepo.list().map((letter) => coverLetterRepo.get(letter.id, true)!).filter(Boolean);
24
+ const jobs = new Map(jobRepo.list().map((job) => [job.id, job]));
25
+ const refs: ReuseReference[] = [];
26
+
27
+ for (const exp of experienceRepo.list()) {
28
+ const needles = [exp.company, exp.role].filter((v): v is string => !!v && v.trim().length >= 2);
29
+ const matches = letters.filter((letter) => {
30
+ const body = letter.current_content ?? '';
31
+ return needles.some((needle) => containsNormalized(body, needle));
32
+ });
33
+ refs.push({
34
+ kind: 'experience',
35
+ entity_id: exp.id,
36
+ label: [exp.company, exp.role].filter(Boolean).join(' · '),
37
+ cover_letters: matches.map((letter) => {
38
+ const job = letter.job_id ? jobs.get(letter.job_id) : null;
39
+ return {
40
+ id: letter.id,
41
+ title: letter.title,
42
+ job_id: letter.job_id,
43
+ company: job?.company ?? null,
44
+ position: job?.position ?? null,
45
+ };
46
+ }),
47
+ reuse_count: matches.length,
48
+ });
49
+ }
50
+
51
+ for (const project of projectRepo.list()) {
52
+ const matches = letters.filter((letter) => containsNormalized(letter.current_content ?? '', project.name));
53
+ refs.push({
54
+ kind: 'project',
55
+ entity_id: project.id,
56
+ label: project.name,
57
+ cover_letters: matches.map((letter) => {
58
+ const job = letter.job_id ? jobs.get(letter.job_id) : null;
59
+ return {
60
+ id: letter.id,
61
+ title: letter.title,
62
+ job_id: letter.job_id,
63
+ company: job?.company ?? null,
64
+ position: job?.position ?? null,
65
+ };
66
+ }),
67
+ reuse_count: matches.length,
68
+ });
69
+ }
70
+
71
+ return refs.sort((a, b) => b.reuse_count - a.reuse_count || a.label.localeCompare(b.label));
72
+ }
73
+
74
+ export function diffText(before: string, after: string): DiffLine[] {
75
+ const a = before.replace(/\r\n/g, '\n').split('\n');
76
+ const b = after.replace(/\r\n/g, '\n').split('\n');
77
+ const rows = a.length + 1;
78
+ const cols = b.length + 1;
79
+ const dp = Array.from({ length: rows }, () => new Uint32Array(cols));
80
+
81
+ for (let i = a.length - 1; i >= 0; i--) {
82
+ for (let j = b.length - 1; j >= 0; j--) {
83
+ dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
84
+ }
85
+ }
86
+
87
+ const result: DiffLine[] = [];
88
+ let i = 0;
89
+ let j = 0;
90
+ while (i < a.length && j < b.length) {
91
+ if (a[i] === b[j]) {
92
+ result.push({ type: 'equal', text: a[i]! });
93
+ i++;
94
+ j++;
95
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
96
+ result.push({ type: 'remove', text: a[i]! });
97
+ i++;
98
+ } else {
99
+ result.push({ type: 'add', text: b[j]! });
100
+ j++;
101
+ }
102
+ }
103
+ while (i < a.length) result.push({ type: 'remove', text: a[i++]! });
104
+ while (j < b.length) result.push({ type: 'add', text: b[j++]! });
105
+ return result;
106
+ }
107
+
108
+ export function diffCoverLetterVersions(coverLetterId: string, beforeVersionId: string, afterVersionId: string) {
109
+ const letter = coverLetterRepo.get(coverLetterId, true);
110
+ if (!letter?.versions) return null;
111
+ const before = letter.versions.find((version) => version.id === beforeVersionId);
112
+ const after = letter.versions.find((version) => version.id === afterVersionId);
113
+ if (!before || !after) return null;
114
+ const lines = diffText(before.content, after.content);
115
+ return {
116
+ cover_letter_id: coverLetterId,
117
+ before: { id: before.id, version_no: before.version_no, source: before.source, created_at: before.created_at },
118
+ after: { id: after.id, version_no: after.version_no, source: after.source, created_at: after.created_at },
119
+ summary: {
120
+ added: lines.filter((line) => line.type === 'add').length,
121
+ removed: lines.filter((line) => line.type === 'remove').length,
122
+ unchanged: lines.filter((line) => line.type === 'equal').length,
123
+ },
124
+ lines,
125
+ };
126
+ }
@@ -14,6 +14,7 @@ import {
14
14
  type CoverLetterVersionInput,
15
15
  type ApplicationStatus,
16
16
  type ApplicationSubmission,
17
+ type RejectedStage,
17
18
  type ApplicationRecord,
18
19
  type InterviewPrepInput,
19
20
  type InterviewPrepRecord,
@@ -350,6 +351,7 @@ export function updateApplicationStatus(
350
351
  status: ApplicationStatus,
351
352
  note?: string,
352
353
  submission?: ApplicationSubmission,
354
+ rejectedStage?: RejectedStage,
353
355
  ): StatusChangeResult {
354
356
  const job = jobRepo.get(jobId);
355
357
  if (!job) throw notFound('공고를 찾을 수 없습니다.');
@@ -358,7 +360,7 @@ export function updateApplicationStatus(
358
360
  // together: if any write throws (e.g. a bad bind), roll back so we never leave
359
361
  // the status changed but the submission/timeline missing.
360
362
  const application = tx(() => {
361
- let application = applicationRepo.setStatus(jobId, status, note);
363
+ let application = applicationRepo.setStatus(jobId, status, note, rejectedStage);
362
364
  const submittedDocs = (submission?.document_ids ?? [])
363
365
  .map((docId) => documentRepo.get(docId))
364
366
  .filter((doc): doc is DocumentRecord => !!doc);
@@ -407,6 +409,8 @@ export function updateApplicationStatus(
407
409
  status_label: APPLICATION_STATUS_LABELS[status],
408
410
  note: note ?? null,
409
411
  submission: submissionPayload,
412
+ // 탈락 회고: rejected로 넘어갈 때 실제 저장된 단계(setStatus가 정합성 처리한 값)를 타임라인에도 남긴다.
413
+ rejected_stage: application.rejected_stage,
410
414
  },
411
415
  occurred_at: submittedAtToOccurrence(submittedAt),
412
416
  });