@team-semicolon/semo-cli 4.12.0 → 4.15.0

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.
@@ -322,6 +322,57 @@ function generateThinRouter(kbFirstBlock) {
322
322
  - 사용자가 "다른 탭에서 뭐 돌아가고 있어?" 같은 질문을 하면 cmux 명령어로 확인
323
323
 
324
324
  ${kbFirstBlock}
325
+
326
+ ---
327
+
328
+ ## 3자 동기화 규칙 (NON-NEGOTIABLE)
329
+
330
+ > 모든 SEMO 프로젝트의 변경은 3자 동기화 관점에서 평가되어야 한다.
331
+ > 3자 = **소스코드** ↔ **KB 포함 DB** ↔ **OpenClaw 봇 워크스페이스 로컬 파일**
332
+
333
+ | 축 | 위치 | 예시 |
334
+ |----|------|------|
335
+ | **소스코드** | 각 프로젝트 레포 | API 라우트, 타입, 마이그레이션 |
336
+ | **KB 포함 DB** | semo-kb (PostgreSQL + 벡터 임베딩) | 프로세스, 봇 역할, 온톨로지 |
337
+ | **봇 로컬 파일** | \`~/.openclaw-{botId}/workspace/\` + \`~/.claude/semo/\` | SOUL.md, skills/, memory/ |
338
+
339
+ **변경 시 체크리스트**:
340
+ 1. **소스코드 변경** → KB와 봇 파일에 반영할 내용이 있는가?
341
+ 2. **KB 변경** → 소스코드나 봇 파일과 불일치가 생기지 않는가?
342
+ 3. **봇 파일 변경** → KB에 기록할 사항이 있는가? 소스코드와 정합성이 맞는가?
343
+
344
+ **추가 규칙**:
345
+ - SoT 위치: DB → DB에서 읽기 (하드코딩 금지). KB → \`semo kb get/search\` 조회.
346
+ - KB 쓰기: 반드시 \`semo kb upsert\` CLI 사용 (임베딩 + 스키마 검증 포함). raw SQL INSERT 금지.
347
+
348
+ ---
349
+
350
+ ## KB CLI 참조 가이드
351
+
352
+ | 정보 | 조회 명령 |
353
+ |------|-----------|
354
+ | 봇 프로필 | \`semo kb get {botId} identity\` |
355
+ | 봇 역할 | \`semo kb get {botId} role\` |
356
+ | SEMO 워크스페이스 규격 | \`semo kb get semo spec workspace-v2\` |
357
+ | 환경변수 | \`semo kb get semo infra env-config\` |
358
+ | 코딩 컨벤션 | \`semo kb get semo process coding-convention\` |
359
+ | 인시던트 기록 | \`semo kb get {service} incident {slug}\` |
360
+ | 의사결정 기록 | \`semo kb get semicolon decision {slug}\` |
361
+ | 경로 모를 때 | \`semo kb search "검색어"\` 또는 \`semo kb ontology --action routing-table\` |
362
+
363
+ ---
364
+
365
+ ## 공통 Enforcement Hooks
366
+
367
+ 아래 훅은 \`~/.openclaw-shared/hooks/\`에 위치하며, 봇 + 로컬 세션 양쪽에서 사용:
368
+
369
+ | 훅 | 트리거 | 역할 |
370
+ |----|--------|------|
371
+ | \`context-router.sh\` | UserPromptSubmit | 메시지 키워드 → KB/GFP/INFRA 컨텍스트 힌트 주입 |
372
+ | \`decision-reminder.sh\` | Stop | 의사결정 패턴 감지 → KB 기록 안 했으면 차단 |
373
+ | \`commitment-guard.sh\` | Stop | 한국어 약속 패턴 → commitment 미등록 시 차단 |
374
+ | \`kb-first-guard.sh\` | Stop | KB 필요 질문에 KB 조회 없이 답변 시 차단 |
375
+ | \`response-length-guard.sh\` | Stop | 봇 응답 20줄 초과 시 차단 |
325
376
  `;
326
377
  // 기존 파일 백업 (마이그레이션)
327
378
  if (fs.existsSync(globalClaudeMd)) {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Service Migration — 기존 운영 서비스를 services 테이블에 이식
3
+ *
4
+ * KB 온톨로지에 service 타입으로 등록된 도메인 중 services에 미등록된 것을
5
+ * 자동으로 이식. KB 엔트리 전수 조사(audit) 후 매핑/비매핑 분류.
6
+ */
7
+ import { Pool } from "pg";
8
+ export interface AuditEntry {
9
+ key: string;
10
+ subKey: string;
11
+ target: string;
12
+ value: string;
13
+ }
14
+ export interface AuditResult {
15
+ domain: string;
16
+ ontologyDescription: string | null;
17
+ ontologyCreatedAt: string | null;
18
+ mapped: {
19
+ key: string;
20
+ target: string;
21
+ value: string;
22
+ }[];
23
+ metadata: {
24
+ key: string;
25
+ value: string;
26
+ }[];
27
+ kbOnly: {
28
+ key: string;
29
+ count: number;
30
+ }[];
31
+ projection: {
32
+ key: string;
33
+ count: number;
34
+ }[];
35
+ warnings: {
36
+ key: string;
37
+ reason: string;
38
+ suggestion: string;
39
+ }[];
40
+ missingRequired: string[];
41
+ }
42
+ export interface MigrationRow {
43
+ project_name: string;
44
+ owner_name: string;
45
+ service_domain: string;
46
+ status: string;
47
+ lifecycle: string;
48
+ launched_at: string | null;
49
+ metadata: Record<string, unknown>;
50
+ }
51
+ export interface MigrationResult {
52
+ domain: string;
53
+ action: "created" | "skipped" | "error";
54
+ projectId?: string;
55
+ audit: AuditResult;
56
+ error?: string;
57
+ }
58
+ export declare const STATUS_MAP: Record<string, {
59
+ lifecycle: string;
60
+ status: string;
61
+ }>;
62
+ export declare function getUnregisteredServices(pool: Pool): Promise<Array<{
63
+ domain: string;
64
+ description: string | null;
65
+ created_at: string | null;
66
+ }>>;
67
+ export declare function getRegisteredServices(pool: Pool): Promise<string[]>;
68
+ export declare function auditServiceKBEntries(pool: Pool, domain: string, ontologyDescription: string | null, ontologyCreatedAt: string | null): Promise<AuditResult>;
69
+ export declare function buildServiceProjectRow(audit: AuditResult): MigrationRow;
70
+ export declare function insertServiceProject(pool: Pool, row: MigrationRow): Promise<string>;
71
+ export declare function writePmSummaryToKB(pool: Pool, domain: string, projectId: string, row: MigrationRow, kbEntryCount: number): Promise<void>;
72
+ export declare function printAuditReport(results: MigrationResult[]): void;
73
+ export declare function printDryRunReport(results: MigrationResult[]): void;
74
+ export interface ServiceProjectRow {
75
+ service_id: string;
76
+ project_name: string;
77
+ service_domain: string | null;
78
+ owner_name: string;
79
+ owner_contact: string | null;
80
+ current_phase: number;
81
+ infra_phase: number | null;
82
+ status: string;
83
+ lifecycle: string;
84
+ launched_at: string | null;
85
+ metadata: Record<string, unknown>;
86
+ created_at: string;
87
+ updated_at: string;
88
+ }
89
+ export declare function getServiceProjectByDomain(pool: Pool, domain: string): Promise<ServiceProjectRow | null>;
90
+ export interface ServiceProjectUpdate {
91
+ status?: string;
92
+ lifecycle?: string;
93
+ current_phase?: number;
94
+ project_name?: string;
95
+ owner_name?: string;
96
+ metadata?: Record<string, unknown>;
97
+ }
98
+ export declare function updateServiceProject(pool: Pool, domain: string, updates: ServiceProjectUpdate): Promise<ServiceProjectRow | null>;
99
+ export interface DiagnoseMismatch {
100
+ field: string;
101
+ kbValue: string;
102
+ spValue: string;
103
+ expected: string;
104
+ }
105
+ export interface DiagnoseResult {
106
+ domain: string;
107
+ verdict: "healthy" | "mismatch" | "kb-only" | "sp-only" | "missing";
108
+ kbEntryCount: number;
109
+ kbKeys: string[];
110
+ serviceProject: ServiceProjectRow | null;
111
+ mismatches: DiagnoseMismatch[];
112
+ missingRequired: string[];
113
+ }
114
+ export declare function diagnoseServiceStatus(pool: Pool, domain: string): Promise<DiagnoseResult>;
@@ -0,0 +1,457 @@
1
+ "use strict";
2
+ /**
3
+ * Service Migration — 기존 운영 서비스를 services 테이블에 이식
4
+ *
5
+ * KB 온톨로지에 service 타입으로 등록된 도메인 중 services에 미등록된 것을
6
+ * 자동으로 이식. KB 엔트리 전수 조사(audit) 후 매핑/비매핑 분류.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.STATUS_MAP = void 0;
13
+ exports.getUnregisteredServices = getUnregisteredServices;
14
+ exports.getRegisteredServices = getRegisteredServices;
15
+ exports.auditServiceKBEntries = auditServiceKBEntries;
16
+ exports.buildServiceProjectRow = buildServiceProjectRow;
17
+ exports.insertServiceProject = insertServiceProject;
18
+ exports.writePmSummaryToKB = writePmSummaryToKB;
19
+ exports.printAuditReport = printAuditReport;
20
+ exports.printDryRunReport = printDryRunReport;
21
+ exports.getServiceProjectByDomain = getServiceProjectByDomain;
22
+ exports.updateServiceProject = updateServiceProject;
23
+ exports.diagnoseServiceStatus = diagnoseServiceStatus;
24
+ const chalk_1 = __importDefault(require("chalk"));
25
+ const kb_1 = require("./kb");
26
+ // ── KB Status Mapping ──
27
+ exports.STATUS_MAP = {
28
+ active: { lifecycle: "ops", status: "active" },
29
+ hold: { lifecycle: "ops", status: "paused" },
30
+ maintenance: { lifecycle: "ops", status: "active" },
31
+ completed: { lifecycle: "sunset", status: "completed" },
32
+ deprecated: { lifecycle: "sunset", status: "completed" },
33
+ };
34
+ // Keys that map directly to services columns
35
+ const COLUMN_KEYS = {
36
+ "base-information": "project_name",
37
+ po: "owner_name",
38
+ status: "status+lifecycle",
39
+ };
40
+ // Keys that go into metadata JSONB
41
+ const METADATA_KEYS = new Set([
42
+ "repo",
43
+ "slack-channel",
44
+ "tech-stack",
45
+ "service-url",
46
+ "bm",
47
+ ]);
48
+ // Keys that stay in KB only (normal)
49
+ const KB_ONLY_KEYS = new Set([
50
+ "current-situation",
51
+ "kpi",
52
+ "milestone",
53
+ "decision",
54
+ "process",
55
+ "infra",
56
+ ]);
57
+ // Projection keys (auto-managed by pm-pipeline)
58
+ const PROJECTION_KEYS = new Set([
59
+ "spec",
60
+ "pm-status",
61
+ "gfp-status",
62
+ "gfp-id",
63
+ "infra-status",
64
+ "pm-summary",
65
+ ]);
66
+ // ── Core Functions ──
67
+ async function getUnregisteredServices(pool) {
68
+ const result = await pool.query(`SELECT o.domain, o.description, o.created_at::text
69
+ FROM semo.ontology o
70
+ WHERE o.entity_type = 'service'
71
+ AND o.domain NOT LIKE 'e2e-%'
72
+ AND NOT EXISTS (
73
+ SELECT 1 FROM semo.services sp WHERE sp.service_domain = o.domain
74
+ )
75
+ ORDER BY o.domain`);
76
+ return result.rows;
77
+ }
78
+ async function getRegisteredServices(pool) {
79
+ const result = await pool.query(`SELECT service_domain FROM semo.services WHERE service_domain IS NOT NULL`);
80
+ return result.rows.map((r) => r.service_domain);
81
+ }
82
+ async function auditServiceKBEntries(pool, domain, ontologyDescription, ontologyCreatedAt) {
83
+ // Fetch ALL KB entries for this domain
84
+ const entriesResult = await pool.query(`SELECT key, sub_key, content
85
+ FROM semo.knowledge_base
86
+ WHERE domain = $1
87
+ ORDER BY key, sub_key`, [domain]);
88
+ const entries = entriesResult.rows.map((e) => ({ ...e, content: (e.content ?? "").substring(0, 500) }));
89
+ // Fetch allowed keys from type schema
90
+ const schemaResult = await pool.query(`SELECT scheme_key, COALESCE(source, 'manual') as source
91
+ FROM semo.kb_type_schema WHERE type_key = 'service'`);
92
+ const allowedKeys = new Set(schemaResult.rows.map((r) => r.scheme_key));
93
+ const audit = {
94
+ domain,
95
+ ontologyDescription,
96
+ ontologyCreatedAt,
97
+ mapped: [],
98
+ metadata: [],
99
+ kbOnly: [],
100
+ projection: [],
101
+ warnings: [],
102
+ missingRequired: [],
103
+ };
104
+ // Group entries by key
105
+ const byKey = new Map();
106
+ for (const e of entries) {
107
+ const arr = byKey.get(e.key) || [];
108
+ arr.push({ sub_key: e.sub_key, content: e.content });
109
+ byKey.set(e.key, arr);
110
+ }
111
+ for (const [key, items] of byKey) {
112
+ if (COLUMN_KEYS[key]) {
113
+ // Maps to services column
114
+ const firstContent = items[0]?.content ?? "";
115
+ audit.mapped.push({
116
+ key,
117
+ target: COLUMN_KEYS[key],
118
+ value: firstContent.substring(0, 200),
119
+ });
120
+ }
121
+ else if (METADATA_KEYS.has(key)) {
122
+ const firstContent = items[0]?.content ?? "";
123
+ audit.metadata.push({ key, value: firstContent.substring(0, 200) });
124
+ }
125
+ else if (KB_ONLY_KEYS.has(key)) {
126
+ audit.kbOnly.push({ key, count: items.length });
127
+ }
128
+ else if (PROJECTION_KEYS.has(key)) {
129
+ audit.projection.push({ key, count: items.length });
130
+ }
131
+ else if (!allowedKeys.has(key)) {
132
+ // Unknown key — not in type schema
133
+ audit.warnings.push({
134
+ key,
135
+ reason: `'${key}'은(는) service 타입 스키마에 등록되지 않은 키`,
136
+ suggestion: `semo kb ontology --action add-key --type service --key ${key} --key-type collection`,
137
+ });
138
+ }
139
+ else {
140
+ // In schema but not in our classification — treat as KB-only
141
+ audit.kbOnly.push({ key, count: items.length });
142
+ }
143
+ }
144
+ // Check required keys
145
+ for (const reqKey of ["base-information", "po", "status"]) {
146
+ if (!byKey.has(reqKey)) {
147
+ audit.missingRequired.push(reqKey);
148
+ }
149
+ }
150
+ return audit;
151
+ }
152
+ function buildServiceProjectRow(audit) {
153
+ // Extract project_name from base-information
154
+ const baseInfo = audit.mapped.find((m) => m.key === "base-information");
155
+ let projectName = audit.domain; // fallback
156
+ if (baseInfo) {
157
+ // Take first line or first sentence as project name
158
+ const firstLine = baseInfo.value.split("\n")[0].trim();
159
+ // Try to extract name pattern like "AXOracle — 오라클 정보 플랫폼"
160
+ const match = firstLine.match(/^([^—–\-]+)/);
161
+ projectName = match ? match[1].trim() : firstLine.substring(0, 100);
162
+ }
163
+ else if (audit.ontologyDescription) {
164
+ const match = audit.ontologyDescription.match(/^([^—–\-]+)/);
165
+ projectName = match ? match[1].trim() : audit.ontologyDescription.substring(0, 100);
166
+ }
167
+ // Extract owner from po
168
+ const poEntry = audit.mapped.find((m) => m.key === "po");
169
+ const ownerName = poEntry ? poEntry.value.split("\n")[0].trim().substring(0, 100) : "TBD";
170
+ // Map status
171
+ const statusEntry = audit.mapped.find((m) => m.key === "status");
172
+ const rawStatus = statusEntry
173
+ ? statusEntry.value.split("\n")[0].trim().toLowerCase()
174
+ : "active";
175
+ const mapping = exports.STATUS_MAP[rawStatus] ?? exports.STATUS_MAP["active"];
176
+ // Build metadata from repo, slack-channel, tech-stack, etc.
177
+ const meta = { source: "kb-migration" };
178
+ for (const m of audit.metadata) {
179
+ meta[m.key] = m.value;
180
+ }
181
+ return {
182
+ project_name: projectName,
183
+ owner_name: ownerName,
184
+ service_domain: audit.domain,
185
+ status: mapping.status,
186
+ lifecycle: mapping.lifecycle,
187
+ launched_at: audit.ontologyCreatedAt,
188
+ metadata: meta,
189
+ };
190
+ }
191
+ async function insertServiceProject(pool, row) {
192
+ const result = await pool.query(`INSERT INTO semo.services
193
+ (project_name, owner_name, service_domain, status, lifecycle, launched_at, metadata)
194
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
195
+ RETURNING service_id`, [
196
+ row.project_name,
197
+ row.owner_name,
198
+ row.service_domain,
199
+ row.status,
200
+ row.lifecycle,
201
+ row.launched_at,
202
+ JSON.stringify(row.metadata),
203
+ ]);
204
+ return result.rows[0].service_id;
205
+ }
206
+ async function writePmSummaryToKB(pool, domain, projectId, row, kbEntryCount) {
207
+ const content = [
208
+ `service_id: ${projectId}`,
209
+ `project_name: ${row.project_name}`,
210
+ `lifecycle: ${row.lifecycle}`,
211
+ `status: ${row.status}`,
212
+ `source: kb-migration`,
213
+ `registered_at: ${new Date().toISOString()}`,
214
+ `kb_entries: ${kbEntryCount}`,
215
+ `owner: ${row.owner_name}`,
216
+ ].join("\n");
217
+ await (0, kb_1.kbUpsert)(pool, {
218
+ domain,
219
+ key: "pm-summary",
220
+ content,
221
+ created_by: "pm-pipeline",
222
+ });
223
+ }
224
+ // ── Reporting ──
225
+ function printAuditReport(results) {
226
+ const created = results.filter((r) => r.action === "created");
227
+ const skipped = results.filter((r) => r.action === "skipped");
228
+ const errors = results.filter((r) => r.action === "error");
229
+ console.log(chalk_1.default.cyan.bold(`\n📊 서비스 이식 결과\n`));
230
+ console.log(` ${chalk_1.default.green(`✓ 등록: ${created.length}`)} ${chalk_1.default.yellow(`⊘ 건너뜀: ${skipped.length}`)} ${chalk_1.default.red(`✗ 오류: ${errors.length}`)}`);
231
+ for (const r of results) {
232
+ const icon = r.action === "created" ? chalk_1.default.green("✓") :
233
+ r.action === "skipped" ? chalk_1.default.yellow("⊘") :
234
+ chalk_1.default.red("✗");
235
+ console.log(`\n ${icon} ${chalk_1.default.bold(r.domain)}`);
236
+ if (r.action === "created") {
237
+ console.log(chalk_1.default.gray(` service_id: ${r.projectId}\n` +
238
+ ` mapped: ${r.audit.mapped.map((m) => m.key).join(", ") || "(없음)"}\n` +
239
+ ` metadata: ${r.audit.metadata.map((m) => m.key).join(", ") || "(없음)"}\n` +
240
+ ` kb-only: ${r.audit.kbOnly.map((k) => `${k.key}(${k.count})`).join(", ") || "(없음)"}`));
241
+ }
242
+ if (r.action === "error") {
243
+ console.log(chalk_1.default.red(` ${r.error}`));
244
+ }
245
+ // Warnings
246
+ if (r.audit.warnings.length > 0) {
247
+ console.log(chalk_1.default.yellow(" ⚠ 비표준 키:"));
248
+ for (const w of r.audit.warnings) {
249
+ console.log(chalk_1.default.yellow(` ${w.key}: ${w.reason}`));
250
+ console.log(chalk_1.default.gray(` → ${w.suggestion}`));
251
+ }
252
+ }
253
+ // Missing required
254
+ if (r.audit.missingRequired.length > 0) {
255
+ console.log(chalk_1.default.yellow(` ⚠ 필수 키 누락: ${r.audit.missingRequired.join(", ")}`));
256
+ }
257
+ }
258
+ console.log();
259
+ }
260
+ function printDryRunReport(results) {
261
+ console.log(chalk_1.default.cyan.bold(`\n🔍 서비스 이식 미리보기 (dry-run)\n`));
262
+ console.log(chalk_1.default.gray(` DB 변경 없이 audit 결과만 표시합니다.\n`));
263
+ for (const r of results) {
264
+ const row = buildServiceProjectRow(r.audit);
265
+ console.log(chalk_1.default.bold(` ${r.domain}`));
266
+ console.log(chalk_1.default.gray(` → project_name: ${row.project_name}\n` +
267
+ ` → owner_name: ${row.owner_name}\n` +
268
+ ` → lifecycle: ${row.lifecycle}, status: ${row.status}\n` +
269
+ ` → launched_at: ${row.launched_at ?? 'NOW()'}\n` +
270
+ ` → metadata keys: ${Object.keys(row.metadata).join(", ")}`));
271
+ if (r.audit.kbOnly.length > 0) {
272
+ console.log(chalk_1.default.gray(` → kb-only: ${r.audit.kbOnly.map((k) => `${k.key}(${k.count})`).join(", ")}`));
273
+ }
274
+ if (r.audit.warnings.length > 0) {
275
+ for (const w of r.audit.warnings) {
276
+ console.log(chalk_1.default.yellow(` ⚠ ${w.key}: ${w.reason}`));
277
+ console.log(chalk_1.default.gray(` → ${w.suggestion}`));
278
+ }
279
+ }
280
+ if (r.audit.missingRequired.length > 0) {
281
+ console.log(chalk_1.default.yellow(` ⚠ 필수 키 누락: ${r.audit.missingRequired.join(", ")}`));
282
+ }
283
+ console.log();
284
+ }
285
+ console.log(chalk_1.default.cyan(` 총 ${results.length}개 서비스 이식 대상\n`));
286
+ }
287
+ async function getServiceProjectByDomain(pool, domain) {
288
+ const result = await pool.query(`SELECT service_id, project_name, service_domain, owner_name, owner_contact,
289
+ current_phase, infra_phase, status, lifecycle,
290
+ launched_at::text, metadata,
291
+ created_at::text, updated_at::text
292
+ FROM semo.services
293
+ WHERE service_domain = $1`, [domain]);
294
+ return result.rows[0] ?? null;
295
+ }
296
+ async function updateServiceProject(pool, domain, updates) {
297
+ const sets = [];
298
+ const params = [];
299
+ let idx = 1;
300
+ if (updates.project_name !== undefined) {
301
+ sets.push(`project_name = $${idx++}`);
302
+ params.push(updates.project_name);
303
+ }
304
+ if (updates.owner_name !== undefined) {
305
+ sets.push(`owner_name = $${idx++}`);
306
+ params.push(updates.owner_name);
307
+ }
308
+ if (updates.current_phase !== undefined) {
309
+ sets.push(`current_phase = $${idx++}`);
310
+ params.push(updates.current_phase);
311
+ }
312
+ if (updates.status !== undefined) {
313
+ sets.push(`status = $${idx++}`);
314
+ params.push(updates.status);
315
+ }
316
+ if (updates.lifecycle !== undefined) {
317
+ sets.push(`lifecycle = $${idx++}`);
318
+ params.push(updates.lifecycle);
319
+ // ops 전환 시 launched_at 자동 설정
320
+ if (updates.lifecycle === "ops") {
321
+ sets.push(`launched_at = COALESCE(launched_at, NOW())`);
322
+ }
323
+ }
324
+ if (updates.metadata !== undefined) {
325
+ sets.push(`metadata = COALESCE(metadata, '{}'::jsonb) || $${idx++}::jsonb`);
326
+ params.push(JSON.stringify(updates.metadata));
327
+ }
328
+ if (sets.length === 0)
329
+ return getServiceProjectByDomain(pool, domain);
330
+ params.push(domain);
331
+ const result = await pool.query(`UPDATE semo.services SET ${sets.join(", ")}
332
+ WHERE service_domain = $${idx}
333
+ RETURNING service_id, project_name, service_domain, owner_name, owner_contact,
334
+ current_phase, infra_phase, status, lifecycle,
335
+ launched_at::text, metadata, created_at::text, updated_at::text`, params);
336
+ return result.rows[0] ?? null;
337
+ }
338
+ async function diagnoseServiceStatus(pool, domain) {
339
+ // 1. KB 도메인 존재 확인
340
+ const ontoResult = await pool.query(`SELECT domain, description, created_at::text
341
+ FROM semo.ontology WHERE domain = $1 AND entity_type = 'service'`, [domain]);
342
+ const kbExists = ontoResult.rows.length > 0;
343
+ // 2. services 조회
344
+ const sp = await getServiceProjectByDomain(pool, domain);
345
+ // 3. 분류
346
+ if (!kbExists && !sp) {
347
+ return {
348
+ domain,
349
+ verdict: "missing",
350
+ kbEntryCount: 0,
351
+ kbKeys: [],
352
+ serviceProject: null,
353
+ mismatches: [],
354
+ missingRequired: [],
355
+ };
356
+ }
357
+ if (!kbExists && sp) {
358
+ return {
359
+ domain,
360
+ verdict: "sp-only",
361
+ kbEntryCount: 0,
362
+ kbKeys: [],
363
+ serviceProject: sp,
364
+ mismatches: [],
365
+ missingRequired: [],
366
+ };
367
+ }
368
+ // KB 엔트리 목록
369
+ const entriesResult = await pool.query(`SELECT DISTINCT key FROM semo.knowledge_base WHERE domain = $1 ORDER BY key`, [domain]);
370
+ const kbKeys = entriesResult.rows.map((r) => r.key);
371
+ const countResult = await pool.query(`SELECT COUNT(*)::int as cnt FROM semo.knowledge_base WHERE domain = $1`, [domain]);
372
+ const kbEntryCount = countResult.rows[0]?.cnt ?? 0;
373
+ if (kbExists && !sp) {
374
+ // 필수 키 체크
375
+ const missingRequired = [];
376
+ for (const req of ["base-information", "po", "status"]) {
377
+ if (!kbKeys.includes(req))
378
+ missingRequired.push(req);
379
+ }
380
+ return {
381
+ domain,
382
+ verdict: "kb-only",
383
+ kbEntryCount,
384
+ kbKeys,
385
+ serviceProject: null,
386
+ mismatches: [],
387
+ missingRequired,
388
+ };
389
+ }
390
+ // 4. 양쪽 다 존재 → 교차 비교
391
+ const mismatches = [];
392
+ // KB status vs services status/lifecycle
393
+ const statusEntry = await pool.query(`SELECT content FROM semo.knowledge_base
394
+ WHERE domain = $1 AND key = 'status' AND (sub_key IS NULL OR sub_key = '')
395
+ LIMIT 1`, [domain]);
396
+ if (statusEntry.rows.length > 0 && sp) {
397
+ const kbStatus = statusEntry.rows[0].content.split("\n")[0].trim().toLowerCase();
398
+ const expected = exports.STATUS_MAP[kbStatus];
399
+ if (expected) {
400
+ if (expected.lifecycle !== sp.lifecycle) {
401
+ mismatches.push({
402
+ field: "lifecycle",
403
+ kbValue: `status=${kbStatus} → lifecycle=${expected.lifecycle}`,
404
+ spValue: sp.lifecycle,
405
+ expected: expected.lifecycle,
406
+ });
407
+ }
408
+ if (expected.status !== sp.status) {
409
+ mismatches.push({
410
+ field: "status",
411
+ kbValue: `status=${kbStatus} → status=${expected.status}`,
412
+ spValue: sp.status,
413
+ expected: expected.status,
414
+ });
415
+ }
416
+ }
417
+ }
418
+ // KB po vs services owner_name
419
+ const poEntry = await pool.query(`SELECT content FROM semo.knowledge_base
420
+ WHERE domain = $1 AND key = 'po' AND (sub_key IS NULL OR sub_key = '')
421
+ LIMIT 1`, [domain]);
422
+ if (poEntry.rows.length > 0 && sp) {
423
+ const kbPo = poEntry.rows[0].content.split("\n")[0].trim().toLowerCase();
424
+ if (kbPo !== sp.owner_name.toLowerCase()) {
425
+ mismatches.push({
426
+ field: "owner",
427
+ kbValue: kbPo,
428
+ spValue: sp.owner_name,
429
+ expected: kbPo,
430
+ });
431
+ }
432
+ }
433
+ // section 존재 여부 vs current_phase
434
+ if (sp && sp.lifecycle === "build" && sp.current_phase > 0) {
435
+ const sectionResult = await pool.query(`SELECT COUNT(*)::int as cnt
436
+ FROM semo.service_sections
437
+ WHERE service_id = $1 AND status = 'approved'`, [sp.service_id]);
438
+ const approvedSections = sectionResult.rows[0]?.cnt ?? 0;
439
+ if (approvedSections === 0 && sp.current_phase > 0) {
440
+ mismatches.push({
441
+ field: "phase",
442
+ kbValue: "승인된 섹션 0개",
443
+ spValue: `current_phase=${sp.current_phase}`,
444
+ expected: "phase=0 (승인된 섹션 없음)",
445
+ });
446
+ }
447
+ }
448
+ return {
449
+ domain,
450
+ verdict: mismatches.length > 0 ? "mismatch" : "healthy",
451
+ kbEntryCount,
452
+ kbKeys,
453
+ serviceProject: sp,
454
+ mismatches,
455
+ missingRequired: [],
456
+ };
457
+ }
@@ -0,0 +1 @@
1
+ npx --no -- commitlint --edit $1
@@ -0,0 +1,11 @@
1
+ module.exports = {
2
+ extends: ['@commitlint/config-conventional'],
3
+ rules: {
4
+ 'type-enum': [
5
+ 2,
6
+ 'always',
7
+ ['feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'ci', 'perf', 'style'],
8
+ ],
9
+ 'subject-max-length': [2, 'always', 100],
10
+ },
11
+ };
@@ -0,0 +1,17 @@
1
+ import eslint from '@eslint/js';
2
+ import tseslint from 'typescript-eslint';
3
+
4
+ export default tseslint.config(
5
+ eslint.configs.recommended,
6
+ ...tseslint.configs.recommended,
7
+ {
8
+ ignores: ['dist/', 'node_modules/'],
9
+ },
10
+ {
11
+ rules: {
12
+ '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
13
+ '@typescript-eslint/no-explicit-any': 'warn',
14
+ '@typescript-eslint/no-require-imports': 'off',
15
+ },
16
+ },
17
+ );
@@ -0,0 +1,19 @@
1
+ name: PR Quality Gate
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [dev, main]
6
+
7
+ jobs:
8
+ quality-check:
9
+ name: Lint + Type Check + Build
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: 20
16
+ - run: npm ci
17
+ - run: npm run lint
18
+ - run: npx tsc --noEmit
19
+ - run: npm run build
@@ -0,0 +1 @@
1
+ npx lint-staged
@@ -0,0 +1 @@
1
+ npm run lint && npx tsc --noEmit