sentix 2.0.1

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.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * api-client.js — AI API 클라이언트 (제로 의존성)
3
+ *
4
+ * Node.js 내장 fetch로 Anthropic/OpenAI/Ollama API를 호출.
5
+ * Engine mode에서만 사용됨.
6
+ */
7
+
8
+ /**
9
+ * 프로바이더 설정으로 API 클라이언트 생성
10
+ * @param {object} provider - loadProvider() 결과
11
+ * @returns {object} { chat(messages, tools) }
12
+ */
13
+ export function createClient(provider) {
14
+ switch (provider.name) {
15
+ case 'claude': return createAnthropicClient(provider);
16
+ case 'openai': return createOpenAIClient(provider);
17
+ case 'ollama': return createOllamaClient(provider);
18
+ default: throw new Error(`Unknown provider: ${provider.name}`);
19
+ }
20
+ }
21
+
22
+ // ── Anthropic (Claude API) ────────────────────────────
23
+
24
+ function createAnthropicClient(provider) {
25
+ const { api_key, base_url, model } = provider.api;
26
+ const url = (base_url || 'https://api.anthropic.com') + '/v1/messages';
27
+
28
+ return {
29
+ name: 'claude',
30
+ async chat(systemPrompt, messages, tools = []) {
31
+ const body = {
32
+ model: model || 'claude-sonnet-4-20250514',
33
+ max_tokens: 16384,
34
+ system: systemPrompt,
35
+ messages,
36
+ };
37
+
38
+ if (tools.length > 0) {
39
+ body.tools = tools.map(t => ({
40
+ name: t.name,
41
+ description: t.description,
42
+ input_schema: t.parameters,
43
+ }));
44
+ }
45
+
46
+ const res = await fetch(url, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'x-api-key': api_key,
51
+ 'anthropic-version': '2023-06-01',
52
+ },
53
+ body: JSON.stringify(body),
54
+ });
55
+
56
+ if (!res.ok) {
57
+ const error = await res.text();
58
+ throw new Error(`Anthropic API error (${res.status}): ${error.slice(0, 200)}`);
59
+ }
60
+
61
+ const data = await res.json();
62
+ return parseAnthropicResponse(data);
63
+ },
64
+ };
65
+ }
66
+
67
+ function parseAnthropicResponse(data) {
68
+ const content = [];
69
+ const toolCalls = [];
70
+
71
+ for (const block of data.content || []) {
72
+ if (block.type === 'text') {
73
+ content.push(block.text);
74
+ } else if (block.type === 'tool_use') {
75
+ toolCalls.push({
76
+ id: block.id,
77
+ name: block.name,
78
+ arguments: block.input,
79
+ });
80
+ }
81
+ }
82
+
83
+ return {
84
+ content: content.join('\n'),
85
+ tool_calls: toolCalls,
86
+ stop_reason: data.stop_reason,
87
+ usage: data.usage,
88
+ };
89
+ }
90
+
91
+ // ── OpenAI ────────────────────────────────────────────
92
+
93
+ function createOpenAIClient(provider) {
94
+ const { api_key, base_url, model } = provider.api;
95
+ const url = (base_url || 'https://api.openai.com/v1') + '/chat/completions';
96
+
97
+ return {
98
+ name: 'openai',
99
+ async chat(systemPrompt, messages, tools = []) {
100
+ const body = {
101
+ model: model || 'gpt-4o',
102
+ messages: [
103
+ { role: 'system', content: systemPrompt },
104
+ ...messages,
105
+ ],
106
+ max_tokens: 16384,
107
+ };
108
+
109
+ if (tools.length > 0) {
110
+ body.tools = tools.map(t => ({
111
+ type: 'function',
112
+ function: {
113
+ name: t.name,
114
+ description: t.description,
115
+ parameters: t.parameters,
116
+ },
117
+ }));
118
+ }
119
+
120
+ const res = await fetch(url, {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'Authorization': `Bearer ${api_key}`,
125
+ },
126
+ body: JSON.stringify(body),
127
+ });
128
+
129
+ if (!res.ok) {
130
+ const error = await res.text();
131
+ throw new Error(`OpenAI API error (${res.status}): ${error.slice(0, 200)}`);
132
+ }
133
+
134
+ const data = await res.json();
135
+ return parseOpenAIResponse(data);
136
+ },
137
+ };
138
+ }
139
+
140
+ function parseOpenAIResponse(data) {
141
+ const choice = data.choices?.[0];
142
+ const msg = choice?.message || {};
143
+ const toolCalls = (msg.tool_calls || []).map(tc => ({
144
+ id: tc.id,
145
+ name: tc.function.name,
146
+ arguments: JSON.parse(tc.function.arguments || '{}'),
147
+ }));
148
+
149
+ return {
150
+ content: msg.content || '',
151
+ tool_calls: toolCalls,
152
+ stop_reason: choice?.finish_reason,
153
+ usage: data.usage,
154
+ };
155
+ }
156
+
157
+ // ── Ollama ────────────────────────────────────────────
158
+
159
+ function createOllamaClient(provider) {
160
+ const { base_url, model } = provider.api;
161
+ const url = (base_url || 'http://localhost:11434') + '/api/chat';
162
+
163
+ return {
164
+ name: 'ollama',
165
+ async chat(systemPrompt, messages, tools = []) {
166
+ const body = {
167
+ model: model || 'qwen2.5-coder:32b',
168
+ messages: [
169
+ { role: 'system', content: systemPrompt },
170
+ ...messages,
171
+ ],
172
+ stream: false,
173
+ };
174
+
175
+ if (tools.length > 0) {
176
+ body.tools = tools.map(t => ({
177
+ type: 'function',
178
+ function: {
179
+ name: t.name,
180
+ description: t.description,
181
+ parameters: t.parameters,
182
+ },
183
+ }));
184
+ }
185
+
186
+ const res = await fetch(url, {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/json' },
189
+ body: JSON.stringify(body),
190
+ });
191
+
192
+ if (!res.ok) {
193
+ const error = await res.text();
194
+ throw new Error(`Ollama API error (${res.status}): ${error.slice(0, 200)}`);
195
+ }
196
+
197
+ const data = await res.json();
198
+ const msg = data.message || {};
199
+ const toolCalls = (msg.tool_calls || []).map(tc => ({
200
+ id: `ollama-${Date.now()}`,
201
+ name: tc.function.name,
202
+ arguments: tc.function.arguments || {},
203
+ }));
204
+
205
+ return {
206
+ content: msg.content || '',
207
+ tool_calls: toolCalls,
208
+ stop_reason: data.done ? 'end_turn' : 'tool_use',
209
+ usage: { input_tokens: data.prompt_eval_count, output_tokens: data.eval_count },
210
+ };
211
+ },
212
+ };
213
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * CHANGELOG.md auto-generation from governor history and ticket index.
3
+ *
4
+ * Matches existing format:
5
+ * ## [x.y.z] — YYYY-MM-DD
6
+ * ### Category
7
+ * - entry
8
+ */
9
+
10
+ import { loadIndex } from './ticket-index.js';
11
+
12
+ /**
13
+ * Build changelog entries from resolved tickets since last version.
14
+ */
15
+ export async function buildFromTickets(ctx) {
16
+ const entries = await loadIndex(ctx);
17
+ const resolved = entries.filter(e =>
18
+ e.status === 'resolved' || e.status === 'closed'
19
+ );
20
+
21
+ const categories = {
22
+ 'New Features': [],
23
+ 'Bug Fixes': [],
24
+ 'Security Fixes': [],
25
+ 'Improvements': [],
26
+ };
27
+
28
+ for (const ticket of resolved) {
29
+ const line = `- \`${ticket.id}\`: ${ticket.title}`;
30
+ if (ticket.type === 'feature') {
31
+ categories['New Features'].push(line);
32
+ } else if (ticket.severity === 'critical' && ticket.title.toLowerCase().includes('security')) {
33
+ categories['Security Fixes'].push(line);
34
+ } else if (ticket.type === 'bug') {
35
+ categories['Bug Fixes'].push(line);
36
+ } else {
37
+ categories['Improvements'].push(line);
38
+ }
39
+ }
40
+
41
+ return categories;
42
+ }
43
+
44
+ /**
45
+ * Generate a formatted changelog entry string.
46
+ */
47
+ export function generateChangelogEntry(version, date, categories) {
48
+ const lines = [`## [${version}] — ${date}`, ''];
49
+
50
+ for (const [category, items] of Object.entries(categories)) {
51
+ if (items.length > 0) {
52
+ lines.push(`### ${category}`, '');
53
+ for (const item of items) {
54
+ lines.push(item);
55
+ }
56
+ lines.push('');
57
+ }
58
+ }
59
+
60
+ return lines.join('\n');
61
+ }
62
+
63
+ /**
64
+ * Prepend a new entry to CHANGELOG.md, preserving existing content.
65
+ */
66
+ export async function prependToChangelog(ctx, entry) {
67
+ let existing = '';
68
+ if (ctx.exists('CHANGELOG.md')) {
69
+ existing = await ctx.readFile('CHANGELOG.md');
70
+ }
71
+
72
+ // Insert after the "# Changelog" header line
73
+ const headerLine = '# Changelog';
74
+ const headerIdx = existing.indexOf(headerLine);
75
+
76
+ let content;
77
+ if (headerIdx !== -1) {
78
+ const afterHeader = headerIdx + headerLine.length;
79
+ content = existing.slice(0, afterHeader) + '\n\n' + entry + '\n---\n' + existing.slice(afterHeader).replace(/^\n+/, '\n');
80
+ } else {
81
+ content = `${headerLine}\n\n${entry}\n---\n\n${existing}`;
82
+ }
83
+
84
+ await ctx.writeFile('CHANGELOG.md', content);
85
+ }
86
+
87
+ /**
88
+ * Generate a changelog entry from governor-state + tickets for a given version.
89
+ */
90
+ export async function generateForVersion(ctx, version) {
91
+ const date = new Date().toISOString().slice(0, 10);
92
+ const categories = await buildFromTickets(ctx);
93
+
94
+ // Also check governor-state for the latest request
95
+ if (ctx.exists('tasks/governor-state.json')) {
96
+ try {
97
+ const state = await ctx.readJSON('tasks/governor-state.json');
98
+ if (state.status === 'completed' && state.request) {
99
+ const line = `- ${state.request} (cycle: ${state.cycle_id})`;
100
+ if (!Object.values(categories).flat().length) {
101
+ categories['Improvements'].push(line);
102
+ }
103
+ }
104
+ } catch {
105
+ // Non-critical
106
+ }
107
+ }
108
+
109
+ return generateChangelogEntry(version, date, categories);
110
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * pipeline.js — Framework mode 체인 파이프라인
3
+ *
4
+ * sentix run을 단일 Claude Code 호출이 아닌 multi-phase 체인으로 실행.
5
+ * 각 phase 사이에 검증 게이트와 테스트를 실행하여 단계별 신뢰를 쌓는다.
6
+ *
7
+ * Phase 구조:
8
+ * 1. PLAN — 티켓 생성 + 실행 계획
9
+ * 2. DEV — 코드 구현 + 테스트 작성
10
+ * [gate: verify-gates + npm test]
11
+ * 3. REVIEW — 자체 리뷰 + 수정
12
+ * [gate: verify-gates]
13
+ * 4. FINALIZE — 버전/학습/README 업데이트
14
+ */
15
+
16
+ import { spawnSync } from 'node:child_process';
17
+ import { runGates } from './verify-gates.js';
18
+
19
+ /**
20
+ * 체인 파이프라인 실행 (Framework mode)
21
+ * @param {string} request - 사용자 요청
22
+ * @param {string} cycleId - 사이클 ID
23
+ * @param {object} state - governor-state.json
24
+ * @param {object} ctx - sentix context
25
+ * @param {object} options - { safetyDirective }
26
+ * @returns {object} { success, phases, gateResults }
27
+ */
28
+ export async function runChainedPipeline(request, cycleId, state, ctx, options = {}) {
29
+ const phases = [];
30
+ const startTime = Date.now();
31
+
32
+ // lessons.md, patterns.md 로드 (있으면)
33
+ const lessons = ctx.exists('tasks/lessons.md')
34
+ ? await ctx.readFile('tasks/lessons.md')
35
+ : '';
36
+ const patterns = ctx.exists('tasks/patterns.md')
37
+ ? await ctx.readFile('tasks/patterns.md')
38
+ : '';
39
+
40
+ const learningContext = [
41
+ lessons.trim() ? `\n--- lessons.md ---\n${lessons.slice(0, 2000)}` : '',
42
+ patterns.trim() ? `\n--- patterns.md ---\n${patterns.slice(0, 1000)}` : '',
43
+ ].filter(Boolean).join('\n');
44
+
45
+ // ── Phase 1: PLAN ─────────────────────────────────
46
+ ctx.log('=== Phase 1: PLAN ===\n');
47
+ state.current_phase = 'plan';
48
+ await ctx.writeJSON('tasks/governor-state.json', state);
49
+
50
+ const planResult = runPhase('plan', [
51
+ 'Read CLAUDE.md first.',
52
+ options.safetyDirective || '',
53
+ 'You are the PLANNER agent. Your ONLY job is:',
54
+ `1. Analyze this request: "${request}"`,
55
+ '2. Create a ticket in tasks/tickets/ using: node bin/sentix.js ticket create "..." or node bin/sentix.js feature add "..."',
56
+ '3. List the specific files that need to be changed (SCOPE)',
57
+ '4. Estimate complexity (low/medium/high)',
58
+ '5. DO NOT write any code. ONLY plan.',
59
+ learningContext,
60
+ ].filter(Boolean).join('\n'), ctx);
61
+
62
+ phases.push({ name: 'plan', ...planResult });
63
+ if (!planResult.success) {
64
+ return { success: false, phases, failedAt: 'plan' };
65
+ }
66
+
67
+ // ── Phase 2: DEV ──────────────────────────────────
68
+ ctx.log('\n=== Phase 2: DEV ===\n');
69
+ state.current_phase = 'dev';
70
+ await ctx.writeJSON('tasks/governor-state.json', state);
71
+
72
+ // 가장 최근 티켓 찾기 (planner가 방금 생성한 것)
73
+ const latestTicket = await getLatestTicket(ctx);
74
+
75
+ const devResult = runPhase('dev', [
76
+ 'Read CLAUDE.md first.',
77
+ options.safetyDirective || '',
78
+ 'You are the DEV agent. Your job:',
79
+ latestTicket ? `Ticket:\n${latestTicket}` : `Request: "${request}"`,
80
+ '',
81
+ '1. Implement the changes described in the ticket',
82
+ '2. Write or update tests',
83
+ '3. Run: npm test — ensure all tests pass',
84
+ '4. Self-verify: check hard rules (no export deletion, no test deletion, scope compliance, <50 net deletions)',
85
+ '5. DO NOT update version, README, or CHANGELOG — that is the FINALIZE phase',
86
+ learningContext,
87
+ ].filter(Boolean).join('\n'), ctx);
88
+
89
+ phases.push({ name: 'dev', ...devResult });
90
+ if (!devResult.success) {
91
+ return { success: false, phases, failedAt: 'dev' };
92
+ }
93
+
94
+ // ── Mid-pipeline gate: test + verify ──────────────
95
+ ctx.log('\n=== Gate: Post-DEV Verification ===\n');
96
+
97
+ // Auto-run tests
98
+ const testResult = spawnSync('npm', ['test'], {
99
+ cwd: ctx.cwd,
100
+ encoding: 'utf-8',
101
+ stdio: 'pipe',
102
+ timeout: 60_000,
103
+ });
104
+
105
+ if (testResult.status === 0) {
106
+ ctx.success('Tests passed');
107
+ } else {
108
+ ctx.warn('Tests failed — REVIEW phase will address this');
109
+ ctx.log(testResult.stdout?.slice(-500) || '');
110
+ }
111
+
112
+ // Verify gates
113
+ const midGate = runGates(ctx.cwd);
114
+ for (const check of midGate.checks) {
115
+ if (check.passed) {
116
+ ctx.success(`[${check.rule}] ${check.detail}`);
117
+ } else {
118
+ ctx.warn(`[${check.rule}] ${check.detail}`);
119
+ }
120
+ }
121
+
122
+ const midGateInfo = midGate.passed
123
+ ? 'All verification gates passed.'
124
+ : `Gate violations: ${midGate.violations.map(v => v.message).join('; ')}`;
125
+
126
+ // ── Phase 3: REVIEW ───────────────────────────────
127
+ ctx.log('\n=== Phase 3: REVIEW ===\n');
128
+ state.current_phase = 'review';
129
+ await ctx.writeJSON('tasks/governor-state.json', state);
130
+
131
+ const reviewResult = runPhase('review', [
132
+ 'Read CLAUDE.md first.',
133
+ 'You are the PR-REVIEW agent. Your job:',
134
+ '',
135
+ `Test results: ${testResult.status === 0 ? 'ALL PASSED' : 'SOME FAILED — fix them'}`,
136
+ `Verification gates: ${midGateInfo}`,
137
+ '',
138
+ '1. Review the git diff (run: git diff)',
139
+ '2. If tests failed, fix the failing tests (fix code, not tests)',
140
+ '3. If gate violations exist, fix them',
141
+ '4. Ensure code quality and hard rule compliance',
142
+ '5. Run: npm test — confirm all pass after fixes',
143
+ ].join('\n'), ctx);
144
+
145
+ phases.push({ name: 'review', ...reviewResult });
146
+
147
+ // ── Phase 4: FINALIZE ─────────────────────────────
148
+ ctx.log('\n=== Phase 4: FINALIZE ===\n');
149
+ state.current_phase = 'finalize';
150
+ await ctx.writeJSON('tasks/governor-state.json', state);
151
+
152
+ const finalResult = runPhase('finalize', [
153
+ 'Read CLAUDE.md first.',
154
+ 'You are finalizing this work cycle. Your job:',
155
+ '',
156
+ '1. If any lessons were learned (failures, retries), add them to tasks/lessons.md',
157
+ '2. If README.md needs updating (new features, changed commands), update it',
158
+ '3. Create a clear git commit with the changes',
159
+ '4. Report what was done',
160
+ ].join('\n'), ctx);
161
+
162
+ phases.push({ name: 'finalize', ...finalResult });
163
+
164
+ // ── Final gate ────────────────────────────────────
165
+ const finalGate = runGates(ctx.cwd);
166
+
167
+ return {
168
+ success: true,
169
+ phases,
170
+ gateResults: finalGate,
171
+ duration_seconds: Math.round((Date.now() - startTime) / 1000),
172
+ test_passed: testResult.status === 0,
173
+ };
174
+ }
175
+
176
+ // ── Phase 실행 ────────────────────────────────────────
177
+
178
+ function runPhase(name, prompt, ctx) {
179
+ const result = spawnSync('claude', ['-p', prompt], {
180
+ cwd: ctx.cwd,
181
+ stdio: 'inherit',
182
+ timeout: 300_000, // 5분 per phase (전체 10분 대신)
183
+ });
184
+
185
+ if (result.error) {
186
+ ctx.error(`Phase ${name} failed: ${result.error.message}`);
187
+ return { success: false, error: result.error.message, exit_code: null };
188
+ }
189
+
190
+ if (result.status !== 0) {
191
+ ctx.error(`Phase ${name} exited with code ${result.status}`);
192
+ return { success: false, error: `exit code ${result.status}`, exit_code: result.status };
193
+ }
194
+
195
+ ctx.success(`Phase ${name} completed`);
196
+ return { success: true, error: null, exit_code: 0 };
197
+ }
198
+
199
+ // ── 최근 티켓 내용 가져오기 ───────────────────────────
200
+
201
+ async function getLatestTicket(ctx) {
202
+ if (!ctx.exists('tasks/tickets/index.json')) return null;
203
+
204
+ try {
205
+ const index = await ctx.readJSON('tasks/tickets/index.json');
206
+ if (index.length === 0) return null;
207
+
208
+ const latest = index[index.length - 1];
209
+ const ticketPath = `tasks/tickets/${latest.id}.md`;
210
+ if (ctx.exists(ticketPath)) {
211
+ return await ctx.readFile(ticketPath);
212
+ }
213
+ } catch {
214
+ // 인덱스 파싱 실패 — 무시
215
+ }
216
+
217
+ return null;
218
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * provider.js — 프로바이더 설정 로더
3
+ *
4
+ * .sentix/config.toml에서 runtime mode와 provider 설정을 읽고,
5
+ * .sentix/providers/{name}.toml에서 API 설정을 로드한다.
6
+ *
7
+ * 간이 TOML 파서 포함 (sentix 서브셋만 지원, 외부 의존성 없음).
8
+ */
9
+
10
+ /**
11
+ * 런타임 모드 가져오기 (config.toml → [runtime].mode)
12
+ * @param {object} ctx
13
+ * @returns {Promise<string>} 'framework' | 'engine'
14
+ */
15
+ export async function getRuntimeMode(ctx) {
16
+ if (!ctx.exists('.sentix/config.toml')) return 'framework';
17
+ const config = parseToml(await ctx.readFile('.sentix/config.toml'));
18
+ return config.runtime?.mode || 'framework';
19
+ }
20
+
21
+ /**
22
+ * 프로바이더 설정 로드
23
+ * @param {object} ctx
24
+ * @returns {Promise<object>} { name, type, api, limits }
25
+ */
26
+ export async function loadProvider(ctx) {
27
+ const config = parseToml(await ctx.readFile('.sentix/config.toml'));
28
+ const providerName = config.provider?.default || 'claude';
29
+ const providerPath = `.sentix/providers/${providerName}.toml`;
30
+
31
+ if (!ctx.exists(providerPath)) {
32
+ throw new Error(`Provider config not found: ${providerPath}`);
33
+ }
34
+
35
+ const providerConfig = parseToml(await ctx.readFile(providerPath));
36
+
37
+ // API 키 환경변수 검증
38
+ const apiKeyEnv = providerConfig.api?.api_key_env;
39
+ const apiKey = apiKeyEnv ? process.env[apiKeyEnv] : null;
40
+
41
+ return {
42
+ name: providerName,
43
+ type: providerConfig.provider?.type || 'cli',
44
+ api: {
45
+ base_url: providerConfig.api?.base_url || '',
46
+ api_key: apiKey,
47
+ api_key_env: apiKeyEnv || '',
48
+ model: providerConfig.api?.model || '',
49
+ },
50
+ limits: {
51
+ max_parallel: parseInt(providerConfig.limits?.max_parallel_agents) || 4,
52
+ timeout_seconds: parseInt(providerConfig.limits?.timeout_seconds) || 600,
53
+ max_retries: parseInt(providerConfig.limits?.max_retries) || 3,
54
+ },
55
+ };
56
+ }
57
+
58
+ // ── 간이 TOML 파서 (sentix 서브셋) ──────────────────
59
+
60
+ /**
61
+ * sentix에서 사용하는 TOML 서브셋만 파싱.
62
+ * 지원: [section], [section.sub], key = "value", key = number, key = true/false
63
+ * 미지원: 인라인 테이블, 배열, 멀티라인 문자열
64
+ */
65
+ export function parseToml(content) {
66
+ const result = {};
67
+ let currentSection = null;
68
+
69
+ for (const rawLine of content.split('\n')) {
70
+ const line = rawLine.trim();
71
+
72
+ // 빈 줄, 주석
73
+ if (!line || line.startsWith('#')) continue;
74
+
75
+ // 섹션 헤더: [section] 또는 [section.sub]
76
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
77
+ if (sectionMatch) {
78
+ currentSection = sectionMatch[1];
79
+ // 중첩 객체 생성
80
+ const parts = currentSection.split('.');
81
+ let obj = result;
82
+ for (const part of parts) {
83
+ if (!obj[part]) obj[part] = {};
84
+ obj = obj[part];
85
+ }
86
+ continue;
87
+ }
88
+
89
+ // key = value
90
+ const kvMatch = line.match(/^(\w+)\s*=\s*(.+)$/);
91
+ if (kvMatch) {
92
+ const [, key, rawValue] = kvMatch;
93
+ const value = parseTomlValue(rawValue.trim());
94
+
95
+ if (currentSection) {
96
+ const parts = currentSection.split('.');
97
+ let obj = result;
98
+ for (const part of parts) {
99
+ if (!obj[part]) obj[part] = {};
100
+ obj = obj[part];
101
+ }
102
+ obj[key] = value;
103
+ } else {
104
+ result[key] = value;
105
+ }
106
+ }
107
+ }
108
+
109
+ return result;
110
+ }
111
+
112
+ function parseTomlValue(raw) {
113
+ // 따옴표 문자열
114
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
115
+ return raw.slice(1, -1);
116
+ }
117
+ // 불리언
118
+ if (raw === 'true') return true;
119
+ if (raw === 'false') return false;
120
+ // 숫자
121
+ const num = Number(raw);
122
+ if (!isNaN(num) && raw !== '') return num;
123
+ // 인라인 주석 제거
124
+ const commentIdx = raw.indexOf('#');
125
+ if (commentIdx > 0) {
126
+ return parseTomlValue(raw.slice(0, commentIdx).trim());
127
+ }
128
+ return raw;
129
+ }