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.
- package/AGENTS.md +11 -7
- package/INSTALL.md +18 -16
- package/README.md +28 -26
- package/THIRD_PARTY_NOTICES.md +2 -3
- package/bin/careermate.mjs +8 -1
- package/dist/career-os/README.md +8 -3
- package/dist/career-os/eop/career-description.md +136 -0
- package/dist/career-os/eop/cover-letter.md +7 -5
- package/dist/career-os/eop/fit-analysis.md +15 -5
- package/dist/career-os/eop/interview-prep.md +209 -21
- package/dist/career-os/eop/job-analysis.md +5 -3
- package/dist/career-os/eop/profile-extraction.md +5 -4
- package/dist/career-os/knowledge/cover-letter.md +4 -4
- package/dist/career-os/knowledge/fit-matching.md +8 -8
- package/dist/career-os/knowledge/interview-behavioral.md +76 -8
- package/dist/career-os/knowledge/interview-technical.md +106 -4
- package/dist/career-os/knowledge/linkedin-profile.md +1 -1
- package/dist/career-os/knowledge/networking-referrals.md +1 -1
- package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/dist/career-os/knowledge/portfolio.md +3 -3
- package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/dist/doctor.mjs +1019 -127
- package/dist/mcp.mjs +1036 -126
- package/dist/migrate.mjs +52 -1
- package/dist/public/app.js +42 -16
- package/dist/public/i18n/en/applications.js +23 -0
- package/dist/public/i18n/en/common.js +22 -0
- package/dist/public/i18n/en/documents.js +133 -0
- package/dist/public/i18n/en/home.js +75 -0
- package/dist/public/i18n/en/interview.js +69 -0
- package/dist/public/i18n/en/jobs.js +152 -0
- package/dist/public/i18n/en/nav.js +18 -0
- package/dist/public/i18n/en/profile.js +201 -0
- package/dist/public/i18n/en/settings.js +127 -0
- package/dist/public/i18n/en/status.js +22 -0
- package/dist/public/i18n/en.js +17 -0
- package/dist/public/i18n/ko/applications.js +23 -0
- package/dist/public/i18n/ko/common.js +22 -0
- package/dist/public/i18n/ko/documents.js +133 -0
- package/dist/public/i18n/ko/home.js +63 -0
- package/dist/public/i18n/ko/interview.js +69 -0
- package/dist/public/i18n/ko/jobs.js +152 -0
- package/dist/public/i18n/ko/nav.js +18 -0
- package/dist/public/i18n/ko/profile.js +201 -0
- package/dist/public/i18n/ko/settings.js +127 -0
- package/dist/public/i18n/ko/status.js +23 -0
- package/dist/public/i18n/ko.js +18 -0
- package/dist/public/i18n.js +55 -0
- package/dist/public/index.html +1 -0
- package/dist/public/lib.js +25 -17
- package/dist/public/pages/applications.js +17 -21
- package/dist/public/pages/documents.js +153 -107
- package/dist/public/pages/home.js +88 -53
- package/dist/public/pages/interview.js +105 -51
- package/dist/public/pages/jobs.js +330 -106
- package/dist/public/pages/profile.js +157 -152
- package/dist/public/pages/settings.js +270 -72
- package/dist/public/styles.css +38 -3
- package/dist/web.mjs +859 -103
- package/docs/FAQ.md +26 -5
- package/docs/MCP_TOOLS.md +26 -12
- package/docs/START_WORKFLOW.md +2 -2
- package/docs/SUPPORTED_AI_APPS.md +13 -0
- package/docs/WORKFLOWS.md +80 -23
- package/docs/career-os/README.md +8 -3
- package/docs/career-os/eop/career-description.md +136 -0
- package/docs/career-os/eop/cover-letter.md +7 -5
- package/docs/career-os/eop/fit-analysis.md +15 -5
- package/docs/career-os/eop/interview-prep.md +209 -21
- package/docs/career-os/eop/job-analysis.md +5 -3
- package/docs/career-os/eop/profile-extraction.md +5 -4
- package/docs/career-os/knowledge/cover-letter.md +4 -4
- package/docs/career-os/knowledge/fit-matching.md +8 -8
- package/docs/career-os/knowledge/interview-behavioral.md +76 -8
- package/docs/career-os/knowledge/interview-technical.md +106 -4
- package/docs/career-os/knowledge/linkedin-profile.md +1 -1
- package/docs/career-os/knowledge/networking-referrals.md +1 -1
- package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/docs/career-os/knowledge/portfolio.md +3 -3
- package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/package.json +2 -2
- package/packages/core/src/onboarding.ts +2 -1
- package/packages/core/src/services.ts +138 -21
- package/packages/core/src/summary.ts +59 -1
- package/packages/core/src/verify/lint.ts +4 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +0 -0
- package/packages/db/src/schema.ts +17 -0
- package/packages/exporters/src/index.ts +28 -17
- package/packages/knowledge/src/index.ts +46 -7
- package/packages/mcp-tools/src/bridge.ts +194 -13
- package/packages/mcp-tools/src/tools.ts +390 -38
- package/packages/mcp-tools/src/update.ts +31 -16
- package/packages/parsers/src/documents.ts +12 -1
- package/packages/prompts/src/humanize.ts +46 -0
- package/packages/prompts/src/index.ts +5 -1
- package/packages/prompts/src/install.ts +4 -4
- package/packages/prompts/src/onboarding.ts +4 -4
- package/packages/prompts/src/system.ts +17 -8
- package/packages/shared/src/enums.ts +14 -4
- package/packages/shared/src/schemas.ts +41 -0
- package/packages/workflows/src/definitions.ts +47 -10
- package/scripts/init.ts +284 -13
- package/dist/site/analytics.js +0 -97
- package/dist/site/favicon.svg +0 -1
- package/dist/site/fonts/PretendardVariable.woff2 +0 -0
- package/dist/site/icon-180.png +0 -0
- package/dist/site/icon-512.png +0 -0
- package/dist/site/index.html +0 -565
- package/dist/site/landing.js +0 -54
- package/dist/site/llms-install.txt +0 -218
- package/dist/site/robots.txt +0 -4
- package/dist/site/sitemap.xml +0 -21
- package/dist/site/start.html +0 -353
- package/dist/site/style.css +0 -559
- package/dist/site/vercel.json +0 -20
|
@@ -40,11 +40,39 @@ export interface HomeSummary {
|
|
|
40
40
|
in_progress: { application: ApplicationRecord; job: JobRecord | null; status_label: string }[];
|
|
41
41
|
recent_cover_letters: CoverLetterRecord[];
|
|
42
42
|
interview_todo: { job: JobRecord; status_label: string }[];
|
|
43
|
+
deadlines: { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number }[];
|
|
44
|
+
followups: { job: JobRecord; days_since: number }[];
|
|
43
45
|
recent_activity: ActivityRecord[];
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
const ACTIVE: ApplicationStatus[] = ['draft', 'planned', 'applied', 'document_passed', 'interview'];
|
|
47
49
|
|
|
50
|
+
// Deadline nudge applies only while the posting still needs action (not yet applied).
|
|
51
|
+
const DEADLINE_ACTIONABLE: ApplicationStatus[] = ['draft', 'planned'];
|
|
52
|
+
// Surface a deadline once it's within this many days (overdue items are always shown).
|
|
53
|
+
const DEADLINE_SOON_DAYS = 14;
|
|
54
|
+
// Suggest a follow-up when an application has sat in 'applied' this many days with no response.
|
|
55
|
+
const FOLLOWUP_STALE_DAYS = 10;
|
|
56
|
+
const DAY_MS = 86400000;
|
|
57
|
+
|
|
58
|
+
/** Whole days from `now`'s local midnight to a `YYYY-MM-DD` date (future > 0, today = 0, past < 0); null if unparseable. */
|
|
59
|
+
function daysUntilDate(dateStr: string | null | undefined, now: Date): number | null {
|
|
60
|
+
if (!dateStr) return null;
|
|
61
|
+
const d = new Date(`${dateStr}T00:00:00`);
|
|
62
|
+
if (isNaN(d.getTime())) return null;
|
|
63
|
+
const today = new Date(now);
|
|
64
|
+
today.setHours(0, 0, 0, 0);
|
|
65
|
+
return Math.round((d.getTime() - today.getTime()) / DAY_MS);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whole days elapsed since an ISO timestamp up to `now`; null if unparseable. */
|
|
69
|
+
function daysSinceTimestamp(iso: string | null | undefined, now: Date): number | null {
|
|
70
|
+
if (!iso) return null;
|
|
71
|
+
const d = new Date(iso);
|
|
72
|
+
if (isNaN(d.getTime())) return null;
|
|
73
|
+
return Math.floor((now.getTime() - d.getTime()) / DAY_MS);
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
export function jobWithMeta(job: JobRecord): JobWithMeta {
|
|
49
77
|
const app = applicationRepo.getByJob(job.id);
|
|
50
78
|
const fit = fitRepo.getByJob(job.id);
|
|
@@ -52,11 +80,12 @@ export function jobWithMeta(job: JobRecord): JobWithMeta {
|
|
|
52
80
|
return { ...job, status, status_label: APPLICATION_STATUS_LABELS[status], fit_score: fit?.score ?? null };
|
|
53
81
|
}
|
|
54
82
|
|
|
55
|
-
export function getHomeSummary(): HomeSummary {
|
|
83
|
+
export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
56
84
|
const onboarding = getOnboardingStatus();
|
|
57
85
|
const jobs = jobRepo.list();
|
|
58
86
|
const applications = applicationRepo.list();
|
|
59
87
|
const coverLetters = coverLetterRepo.list();
|
|
88
|
+
const statusByJob = new Map(applications.map((a) => [a.job_id, a]));
|
|
60
89
|
|
|
61
90
|
const status_breakdown = (Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[]).map((status) => ({
|
|
62
91
|
status,
|
|
@@ -75,6 +104,33 @@ export function getHomeSummary(): HomeSummary {
|
|
|
75
104
|
})
|
|
76
105
|
.filter((x): x is { job: JobRecord; status_label: string } => x !== null);
|
|
77
106
|
|
|
107
|
+
// Deadline nudges: postings still awaiting action (draft/planned) whose deadline
|
|
108
|
+
// is overdue or within DEADLINE_SOON_DAYS. Most urgent (overdue) first.
|
|
109
|
+
const deadlines = jobs
|
|
110
|
+
.map((job) => {
|
|
111
|
+
const status = statusByJob.get(job.id)?.status ?? 'draft';
|
|
112
|
+
if (!DEADLINE_ACTIONABLE.includes(status)) return null;
|
|
113
|
+
const days_left = daysUntilDate(job.deadline, now);
|
|
114
|
+
if (days_left == null || days_left > DEADLINE_SOON_DAYS) return null;
|
|
115
|
+
return { job, status, status_label: APPLICATION_STATUS_LABELS[status], days_left };
|
|
116
|
+
})
|
|
117
|
+
.filter((x): x is { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number } => x !== null)
|
|
118
|
+
.sort((a, b) => a.days_left - b.days_left)
|
|
119
|
+
.slice(0, 5);
|
|
120
|
+
|
|
121
|
+
// Follow-up nudges: applications sitting in 'applied' with no response for a while.
|
|
122
|
+
const followups = applications
|
|
123
|
+
.filter((a) => a.status === 'applied')
|
|
124
|
+
.map((a) => {
|
|
125
|
+
const days_since = daysSinceTimestamp(a.applied_at, now);
|
|
126
|
+
if (days_since == null || days_since < FOLLOWUP_STALE_DAYS) return null;
|
|
127
|
+
const job = jobRepo.get(a.job_id);
|
|
128
|
+
return job ? { job, days_since } : null;
|
|
129
|
+
})
|
|
130
|
+
.filter((x): x is { job: JobRecord; days_since: number } => x !== null)
|
|
131
|
+
.sort((a, b) => b.days_since - a.days_since)
|
|
132
|
+
.slice(0, 5);
|
|
133
|
+
|
|
78
134
|
return {
|
|
79
135
|
onboarding,
|
|
80
136
|
counts: {
|
|
@@ -95,6 +151,8 @@ export function getHomeSummary(): HomeSummary {
|
|
|
95
151
|
}),
|
|
96
152
|
recent_cover_letters: coverLetters.slice(0, 5),
|
|
97
153
|
interview_todo,
|
|
154
|
+
deadlines,
|
|
155
|
+
followups,
|
|
98
156
|
recent_activity: activityRepo.recent(10),
|
|
99
157
|
};
|
|
100
158
|
}
|
|
@@ -198,6 +198,10 @@ export function lintArtifact(
|
|
|
198
198
|
const strict = !!opts.strict;
|
|
199
199
|
|
|
200
200
|
const blocking: LintReport['blocking'] = [];
|
|
201
|
+
// Save-gate currently fires for cover letters only. The other `kind`s
|
|
202
|
+
// ('resume' | 'fit' | 'interview') are accepted and linted (signals/provenance
|
|
203
|
+
// still computed) but not yet hard-gated — that's the deferred B-2 scope, not a
|
|
204
|
+
// dead branch. See docs/career-os EOP B-2.
|
|
201
205
|
if (corpusAvailable && kind === 'cover_letter') {
|
|
202
206
|
// Always block suspected fabrication (numbers tracing to nothing).
|
|
203
207
|
if (provenance.fabricated.length > 0) {
|
package/packages/db/src/index.ts
CHANGED
|
Binary file
|
|
@@ -283,6 +283,23 @@ export const MIGRATIONS: string[] = [
|
|
|
283
283
|
COMMIT;
|
|
284
284
|
PRAGMA foreign_keys=ON;
|
|
285
285
|
`,
|
|
286
|
+
|
|
287
|
+
// v3 — per-job timeline entries. Document/cover-letter refs live in payload
|
|
288
|
+
// snapshots instead of FKs so deleting a document does not erase the history.
|
|
289
|
+
`
|
|
290
|
+
CREATE TABLE IF NOT EXISTS application_timeline_events (
|
|
291
|
+
id TEXT PRIMARY KEY,
|
|
292
|
+
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
293
|
+
type TEXT NOT NULL,
|
|
294
|
+
title TEXT NOT NULL,
|
|
295
|
+
summary TEXT,
|
|
296
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
297
|
+
occurred_at TEXT NOT NULL,
|
|
298
|
+
created_at TEXT NOT NULL
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
|
|
302
|
+
`,
|
|
286
303
|
];
|
|
287
304
|
|
|
288
305
|
export function migrate(db: DatabaseSync): { from: number; to: number } {
|
|
@@ -284,23 +284,34 @@ export function interviewPrepToMarkdown(
|
|
|
284
284
|
? joinBlocks(['## 1분 자기소개', prep.self_introduction.trim()])
|
|
285
285
|
: '';
|
|
286
286
|
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
287
|
+
const renderQuestion = (q: InterviewPrepRecord['questions'][number], idx: number, level: string) =>
|
|
288
|
+
joinBlocks([
|
|
289
|
+
`${level} Q${idx + 1}. ${q.question}`,
|
|
290
|
+
q.intent?.trim() ? kv('의도', q.intent) : '',
|
|
291
|
+
q.answer_outline?.trim()
|
|
292
|
+
? joinBlocks(['**답변 가이드**', q.answer_outline.trim()])
|
|
293
|
+
: '',
|
|
294
|
+
q.followups?.length
|
|
295
|
+
? joinBlocks(['**예상 꼬리 질문**', bulletList(q.followups)])
|
|
296
|
+
: '',
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
let questionsBlock = '';
|
|
300
|
+
if (prep.questions?.length) {
|
|
301
|
+
const technical = prep.questions.filter((q) => (q.category ?? 'technical') === 'technical');
|
|
302
|
+
const behavioral = prep.questions.filter((q) => q.category === 'behavioral');
|
|
303
|
+
// Split into 기술 / 인성·컬처핏 sub-sections only when both tracks are present;
|
|
304
|
+
// a single-track sheet stays flat (backward-compatible output).
|
|
305
|
+
questionsBlock = behavioral.length
|
|
306
|
+
? joinBlocks([
|
|
307
|
+
'## 예상 질문',
|
|
308
|
+
technical.length
|
|
309
|
+
? joinBlocks(['### 기술 면접', ...technical.map((q, i) => renderQuestion(q, i, '####'))])
|
|
310
|
+
: '',
|
|
311
|
+
joinBlocks(['### 인성·컬처핏 면접', ...behavioral.map((q, i) => renderQuestion(q, i, '####'))]),
|
|
312
|
+
])
|
|
313
|
+
: joinBlocks(['## 예상 질문', ...prep.questions.map((q, i) => renderQuestion(q, i, '###'))]);
|
|
314
|
+
}
|
|
304
315
|
|
|
305
316
|
const starBlock = prep.star_guides?.length
|
|
306
317
|
? joinBlocks([
|
|
@@ -58,12 +58,13 @@ export const VERIFIER_IDS = [
|
|
|
58
58
|
] as const;
|
|
59
59
|
export type VerifierId = (typeof VERIFIER_IDS)[number];
|
|
60
60
|
|
|
61
|
-
/**
|
|
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,7 +110,7 @@ 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',
|
|
@@ -120,12 +121,47 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
|
|
|
120
121
|
verifierSequence: ['human-voice', 'truthfulness', 'ats-compat', 'consistency'],
|
|
121
122
|
loop: 'draft_verify_revise',
|
|
122
123
|
},
|
|
124
|
+
write_career_description: {
|
|
125
|
+
eop: 'career-description',
|
|
126
|
+
expertSequence: ['resume', 'ats'],
|
|
127
|
+
verifierSequence: ['truthfulness', 'consistency', 'ats-compat'],
|
|
128
|
+
loop: 'draft_verify_revise',
|
|
129
|
+
},
|
|
123
130
|
prepare_interview: {
|
|
124
131
|
eop: 'interview-prep',
|
|
125
|
-
expertSequence: ['interview-behavioral', 'interview-technical', 'company-research'],
|
|
132
|
+
expertSequence: ['recruiter-screen', 'interview-behavioral', 'interview-technical', 'company-research'],
|
|
126
133
|
verifierSequence: ['truthfulness', 'consistency'],
|
|
127
134
|
loop: 'draft_verify_revise',
|
|
128
135
|
},
|
|
136
|
+
// R7 A2: manage_application_status had no route → rejection-triage / offer-evaluation /
|
|
137
|
+
// salary-negotiation were orphaned (served only on-demand via get_playbook). Route the
|
|
138
|
+
// decision-stage playbooks so they are pushed when the user manages application outcomes.
|
|
139
|
+
// R7 A4: A2 routed the decision-stage playbooks, but the EOP-free lifecycle domains
|
|
140
|
+
// onboarding-first-90-days and networking-referrals were still on-demand-only. Anchor them
|
|
141
|
+
// here (the only EOP-free lifecycle route). NOTE: networking's strongest lever is *before*
|
|
142
|
+
// applied (pre-referral, networking-referrals §2) — this route is the surfacing point, not
|
|
143
|
+
// the recommended timing, so surface networking early when the user is pre-application.
|
|
144
|
+
// linkedin-profile/portfolio stay on-demand pending a dedicated personal-brand workflow id
|
|
145
|
+
// (FIXES.md / STATUS §7.9 follow-up — onboarding EOP is résumé-import-only, so brand-
|
|
146
|
+
// creation playbooks would blur its stage meaning). Additive wiring (A1/A2 precedent).
|
|
147
|
+
manage_application_status: {
|
|
148
|
+
expertSequence: [
|
|
149
|
+
'rejection-triage-iteration',
|
|
150
|
+
'offer-evaluation-decision',
|
|
151
|
+
'salary-negotiation',
|
|
152
|
+
'onboarding-first-90-days',
|
|
153
|
+
'networking-referrals',
|
|
154
|
+
],
|
|
155
|
+
verifierSequence: ['truthfulness', 'consistency'],
|
|
156
|
+
},
|
|
157
|
+
// R7 후속: linkedin-profile·portfolio는 onboarding(이력서 임포트)과 단계 의미가
|
|
158
|
+
// 달라(개인 브랜드 자산 *생성*) 전용 워크플로우로 분리(consensus 권고). networking은
|
|
159
|
+
// manage_application_status에 이미 정박. 저장은 add_resume(kind=portfolio); linkedin
|
|
160
|
+
// 전용 저장/검증은 Phase B.
|
|
161
|
+
build_personal_brand: {
|
|
162
|
+
expertSequence: ['linkedin-profile', 'portfolio'],
|
|
163
|
+
verifierSequence: ['truthfulness', 'human-voice', 'consistency'],
|
|
164
|
+
},
|
|
129
165
|
};
|
|
130
166
|
|
|
131
167
|
export function getRoute(id: string): CareerRoute | undefined {
|
|
@@ -142,12 +178,15 @@ export function renderRouteGuide(id: string): string | undefined {
|
|
|
142
178
|
const route = CAREER_ROUTES[id];
|
|
143
179
|
if (!route) return undefined;
|
|
144
180
|
const lines: string[] = [];
|
|
145
|
-
|
|
181
|
+
const eops = route.eop ? (Array.isArray(route.eop) ? route.eop : [route.eop]) : [];
|
|
182
|
+
if (eops.length) {
|
|
146
183
|
lines.push('---', '');
|
|
147
184
|
lines.push('## 전문가 실행 절차 (EOP) — 즉답하지 말고 이 절차를 따르세요');
|
|
148
185
|
lines.push('');
|
|
149
|
-
|
|
150
|
-
|
|
186
|
+
for (const e of eops) {
|
|
187
|
+
lines.push(getEop(e));
|
|
188
|
+
lines.push('');
|
|
189
|
+
}
|
|
151
190
|
}
|
|
152
191
|
lines.push('## 이 작업에 적용할 전문가 플레이북');
|
|
153
192
|
lines.push('작성 직전, 아래 도메인의 깊은 지식을 받아 적용하세요:');
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `open_dashboard` / `open_application` need the running web server's URL. The
|
|
5
5
|
* web server publishes it to a runtime file (see @careermate/db runtime). If the
|
|
6
|
-
* server isn't running we start it
|
|
6
|
+
* server isn't running we start it in the background and wait for it to register.
|
|
7
7
|
*/
|
|
8
|
-
import { spawn, type SpawnOptions } from 'node:child_process';
|
|
8
|
+
import { spawn, spawnSync, type SpawnOptions } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { readRuntimeInfo, isProcessAlive } from '@careermate/db';
|
|
12
|
+
import { getDataDir, readRuntimeInfo, isProcessAlive, type RuntimeInfo } from '@careermate/db';
|
|
12
13
|
import { BUNDLED } from '@careermate/shared';
|
|
13
14
|
|
|
14
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -19,9 +20,103 @@ export function projectRoot(): string {
|
|
|
19
20
|
return REPO_ROOT;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
const HEALTH_TIMEOUT_MS = 800;
|
|
24
|
+
let pendingStart: Promise<{ url: string } | null> | null = null;
|
|
25
|
+
|
|
26
|
+
function dashboardLogPath(): string {
|
|
27
|
+
return path.join(getDataDir(), 'server.log');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function launchdPlistPath(): string {
|
|
31
|
+
return path.join(getDataDir(), 'dashboard.launchd.plist');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function launchdLabel(): string {
|
|
35
|
+
let hash = 5381;
|
|
36
|
+
for (const ch of getDataDir()) hash = ((hash * 33) ^ ch.charCodeAt(0)) >>> 0;
|
|
37
|
+
return `life.careermate.dashboard.${hash.toString(16)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function xmlEscape(value: string): string {
|
|
41
|
+
return value
|
|
42
|
+
.replace(/&/g, '&')
|
|
43
|
+
.replace(/</g, '<')
|
|
44
|
+
.replace(/>/g, '>')
|
|
45
|
+
.replace(/"/g, '"')
|
|
46
|
+
.replace(/'/g, ''');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function plistString(key: string, value: string): string {
|
|
50
|
+
return ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderLaunchdPlist(bin: string, args: string[], cwd: string, log: string): string {
|
|
54
|
+
const env: Record<string, string> = {
|
|
55
|
+
CAREERMATE_DATA_DIR: getDataDir(),
|
|
56
|
+
CAREERMATE_NO_OPEN: '1',
|
|
57
|
+
};
|
|
58
|
+
if (process.env.CAREERMATE_PORT) env.CAREERMATE_PORT = process.env.CAREERMATE_PORT;
|
|
59
|
+
if (process.env.PATH) env.PATH = process.env.PATH;
|
|
60
|
+
|
|
61
|
+
const programArgs = [bin, ...args].map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n');
|
|
62
|
+
const envEntries = Object.entries(env).map(([key, value]) => `${plistString(key, value)}`).join('\n');
|
|
63
|
+
|
|
64
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
65
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
66
|
+
<plist version="1.0">
|
|
67
|
+
<dict>
|
|
68
|
+
<key>Label</key>
|
|
69
|
+
<string>${xmlEscape(launchdLabel())}</string>
|
|
70
|
+
<key>ProgramArguments</key>
|
|
71
|
+
<array>
|
|
72
|
+
${programArgs}
|
|
73
|
+
</array>
|
|
74
|
+
<key>WorkingDirectory</key>
|
|
75
|
+
<string>${xmlEscape(cwd)}</string>
|
|
76
|
+
<key>EnvironmentVariables</key>
|
|
77
|
+
<dict>
|
|
78
|
+
${envEntries}
|
|
79
|
+
</dict>
|
|
80
|
+
<key>RunAtLoad</key>
|
|
81
|
+
<true/>
|
|
82
|
+
<key>KeepAlive</key>
|
|
83
|
+
<false/>
|
|
84
|
+
<key>StandardOutPath</key>
|
|
85
|
+
<string>${xmlEscape(log)}</string>
|
|
86
|
+
<key>StandardErrorPath</key>
|
|
87
|
+
<string>${xmlEscape(log)}</string>
|
|
88
|
+
</dict>
|
|
89
|
+
</plist>
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function delay(ms: number): Promise<void> {
|
|
94
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function pingDashboard(url: string, timeoutMs = HEALTH_TIMEOUT_MS): Promise<boolean> {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
100
|
+
timer.unref?.();
|
|
101
|
+
try {
|
|
102
|
+
const healthUrl = new URL('/api/health', url);
|
|
103
|
+
const res = await fetch(healthUrl, {
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
headers: { accept: 'application/json' },
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) return false;
|
|
108
|
+
const body = (await res.json().catch(() => null)) as { ok?: unknown } | null;
|
|
109
|
+
return body?.ok === true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function liveServer(): Promise<RuntimeInfo | null> {
|
|
23
118
|
const info = readRuntimeInfo();
|
|
24
|
-
if (info && isProcessAlive(info.pid)) return info;
|
|
119
|
+
if (info && isProcessAlive(info.pid) && await pingDashboard(info.url)) return info;
|
|
25
120
|
return null;
|
|
26
121
|
}
|
|
27
122
|
|
|
@@ -33,36 +128,122 @@ function liveServer() {
|
|
|
33
128
|
* guards the synchronous spawn() call, not the later event — so attach the
|
|
34
129
|
* listener here. stdio defaults to 'ignore'; callers may override via opts.
|
|
35
130
|
*/
|
|
36
|
-
function spawnDetached(cmd: string, args: string[], opts: SpawnOptions = {}):
|
|
131
|
+
function spawnDetached(cmd: string, args: string[], opts: SpawnOptions = {}): number | null {
|
|
37
132
|
try {
|
|
38
133
|
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, ...opts });
|
|
39
134
|
child.on('error', () => {
|
|
40
135
|
/* binary missing / spawn failed — best-effort, never crash */
|
|
41
136
|
});
|
|
42
137
|
child.unref();
|
|
138
|
+
return child.pid ?? null;
|
|
43
139
|
} catch {
|
|
44
140
|
/* best-effort */
|
|
141
|
+
return null;
|
|
45
142
|
}
|
|
46
143
|
}
|
|
47
144
|
|
|
48
|
-
|
|
145
|
+
function spawnViaLaunchd(bin: string, args: string[], cwd: string, log: string): boolean {
|
|
146
|
+
if (process.platform !== 'darwin' || process.env.CAREERMATE_LAUNCHER === 'detached') return false;
|
|
147
|
+
if (typeof process.getuid !== 'function') return false;
|
|
148
|
+
|
|
149
|
+
const domain = `gui/${process.getuid()}`;
|
|
150
|
+
const label = launchdLabel();
|
|
151
|
+
const plist = launchdPlistPath();
|
|
152
|
+
try {
|
|
153
|
+
fs.writeFileSync(plist, renderLaunchdPlist(bin, args, cwd, log), 'utf8');
|
|
154
|
+
// Remove a stale loaded job for the same data dir. If it is not loaded,
|
|
155
|
+
// launchctl exits non-zero; that is fine and should not block bootstrap.
|
|
156
|
+
spawnSync('launchctl', ['bootout', `${domain}/${label}`], { stdio: 'ignore' });
|
|
157
|
+
const res = spawnSync('launchctl', ['bootstrap', domain, plist], {
|
|
158
|
+
encoding: 'utf8',
|
|
159
|
+
timeout: 5000,
|
|
160
|
+
});
|
|
161
|
+
if (res.status === 0) return true;
|
|
162
|
+
fs.appendFileSync(log, `[${new Date().toISOString()}] launchctl bootstrap failed: ${res.stderr || res.stdout || res.status}\n`);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
try {
|
|
165
|
+
fs.appendFileSync(log, `[${new Date().toISOString()}] launchctl launch failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
166
|
+
} catch {
|
|
167
|
+
/* ignore */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function spawnDashboardDetached(bin: string, args: string[], cwd: string, logFd: number | null): void {
|
|
174
|
+
spawnDetached(bin, args, {
|
|
175
|
+
cwd,
|
|
176
|
+
env: { ...process.env, CAREERMATE_NO_OPEN: '1' },
|
|
177
|
+
stdio: logFd === null ? 'ignore' : ['ignore', logFd, logFd],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Start the dashboard server in the background and wait until it publishes its address. */
|
|
49
182
|
export async function ensureServer(timeoutMs = 12000): Promise<{ url: string } | null> {
|
|
50
|
-
const existing = liveServer();
|
|
183
|
+
const existing = await liveServer();
|
|
51
184
|
if (existing) return { url: existing.url };
|
|
185
|
+
if (pendingStart) return pendingStart;
|
|
52
186
|
|
|
187
|
+
pendingStart = startServerBackground(timeoutMs);
|
|
188
|
+
try {
|
|
189
|
+
return await pendingStart;
|
|
190
|
+
} finally {
|
|
191
|
+
pendingStart = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function startServerBackground(timeoutMs: number): Promise<{ url: string } | null> {
|
|
53
196
|
// prod(번들): dist/web.mjs를 플레인 node로 실행. dev: tsx로 소스 실행.
|
|
54
197
|
const [bin, args, cwd] = BUNDLED
|
|
55
198
|
? [process.execPath, ['--experimental-sqlite', path.join(__dirname, 'web.mjs')], __dirname]
|
|
56
|
-
: [
|
|
199
|
+
: [
|
|
200
|
+
process.execPath,
|
|
201
|
+
['--no-warnings', '--experimental-sqlite', '--import', 'tsx', path.join(REPO_ROOT, 'apps/web/src/index.ts')],
|
|
202
|
+
REPO_ROOT,
|
|
203
|
+
];
|
|
57
204
|
// Spawn failures (sync or async) just leave the server down; the poll loop
|
|
58
205
|
// below then times out and returns null — no crash.
|
|
59
|
-
|
|
206
|
+
const log = dashboardLogPath();
|
|
207
|
+
let logFd: number | null = null;
|
|
208
|
+
try {
|
|
209
|
+
logFd = fs.openSync(log, 'a');
|
|
210
|
+
fs.writeSync(logFd, `\n[${new Date().toISOString()}] CareerMate dashboard background launch\n`);
|
|
211
|
+
} catch {
|
|
212
|
+
logFd = null;
|
|
213
|
+
}
|
|
214
|
+
let launchedViaLaunchd = false;
|
|
215
|
+
try {
|
|
216
|
+
launchedViaLaunchd = spawnViaLaunchd(bin as string, args as string[], cwd as string, log);
|
|
217
|
+
if (!launchedViaLaunchd) spawnDashboardDetached(bin as string, args as string[], cwd as string, logFd);
|
|
218
|
+
} finally {
|
|
219
|
+
if (logFd !== null) {
|
|
220
|
+
try { fs.closeSync(logFd); } catch { /* ignore */ }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
60
223
|
|
|
61
224
|
const start = Date.now();
|
|
62
225
|
while (Date.now() - start < timeoutMs) {
|
|
63
|
-
const info = liveServer();
|
|
226
|
+
const info = await liveServer();
|
|
64
227
|
if (info) return { url: info.url };
|
|
65
|
-
await
|
|
228
|
+
await delay(300);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// If launchd accepted the job but the server never became healthy, give the
|
|
232
|
+
// simpler detached path one chance. This protects dev/source installs where
|
|
233
|
+
// launchd has a thinner environment than the invoking AI client.
|
|
234
|
+
if (launchedViaLaunchd) {
|
|
235
|
+
try {
|
|
236
|
+
fs.appendFileSync(log, `[${new Date().toISOString()}] launchd did not produce a healthy server; falling back to detached spawn\n`);
|
|
237
|
+
} catch {
|
|
238
|
+
/* ignore */
|
|
239
|
+
}
|
|
240
|
+
spawnDashboardDetached(bin as string, args as string[], cwd as string, null);
|
|
241
|
+
const fallbackStart = Date.now();
|
|
242
|
+
while (Date.now() - fallbackStart < Math.min(timeoutMs, 8000)) {
|
|
243
|
+
const info = await liveServer();
|
|
244
|
+
if (info) return { url: info.url };
|
|
245
|
+
await delay(300);
|
|
246
|
+
}
|
|
66
247
|
}
|
|
67
248
|
return null;
|
|
68
249
|
}
|
|
@@ -98,7 +279,7 @@ export function openInFileManager(dir: string): void {
|
|
|
98
279
|
|
|
99
280
|
/** Resolve the dashboard URL, starting the server if needed. */
|
|
100
281
|
export async function resolveDashboardUrl(hash = ''): Promise<{ url: string; running: boolean }> {
|
|
101
|
-
const live = liveServer();
|
|
282
|
+
const live = await liveServer();
|
|
102
283
|
if (live) return { url: live.url + hash, running: true };
|
|
103
284
|
const started = await ensureServer();
|
|
104
285
|
if (started) return { url: started.url + hash, running: true };
|