@team-semicolon/semo-cli 4.13.0 → 4.15.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.
@@ -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)) {
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Service Migration — 기존 운영 서비스를 service_projects 테이블에 이식
2
+ * Service Migration — 기존 운영 서비스를 services 테이블에 이식
3
3
  *
4
- * KB 온톨로지에 service 타입으로 등록된 도메인 중 service_projects에 미등록된 것을
4
+ * KB 온톨로지에 service 타입으로 등록된 도메인 중 services에 미등록된 것을
5
5
  * 자동으로 이식. KB 엔트리 전수 조사(audit) 후 매핑/비매핑 분류.
6
6
  */
7
7
  import { Pool } from "pg";
@@ -55,6 +55,10 @@ export interface MigrationResult {
55
55
  audit: AuditResult;
56
56
  error?: string;
57
57
  }
58
+ export declare const STATUS_MAP: Record<string, {
59
+ lifecycle: string;
60
+ status: string;
61
+ }>;
58
62
  export declare function getUnregisteredServices(pool: Pool): Promise<Array<{
59
63
  domain: string;
60
64
  description: string | null;
@@ -67,3 +71,44 @@ export declare function insertServiceProject(pool: Pool, row: MigrationRow): Pro
67
71
  export declare function writePmSummaryToKB(pool: Pool, domain: string, projectId: string, row: MigrationRow, kbEntryCount: number): Promise<void>;
68
72
  export declare function printAuditReport(results: MigrationResult[]): void;
69
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>;
@@ -1,14 +1,15 @@
1
1
  "use strict";
2
2
  /**
3
- * Service Migration — 기존 운영 서비스를 service_projects 테이블에 이식
3
+ * Service Migration — 기존 운영 서비스를 services 테이블에 이식
4
4
  *
5
- * KB 온톨로지에 service 타입으로 등록된 도메인 중 service_projects에 미등록된 것을
5
+ * KB 온톨로지에 service 타입으로 등록된 도메인 중 services에 미등록된 것을
6
6
  * 자동으로 이식. KB 엔트리 전수 조사(audit) 후 매핑/비매핑 분류.
7
7
  */
8
8
  var __importDefault = (this && this.__importDefault) || function (mod) {
9
9
  return (mod && mod.__esModule) ? mod : { "default": mod };
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.STATUS_MAP = void 0;
12
13
  exports.getUnregisteredServices = getUnregisteredServices;
13
14
  exports.getRegisteredServices = getRegisteredServices;
14
15
  exports.auditServiceKBEntries = auditServiceKBEntries;
@@ -17,17 +18,20 @@ exports.insertServiceProject = insertServiceProject;
17
18
  exports.writePmSummaryToKB = writePmSummaryToKB;
18
19
  exports.printAuditReport = printAuditReport;
19
20
  exports.printDryRunReport = printDryRunReport;
21
+ exports.getServiceProjectByDomain = getServiceProjectByDomain;
22
+ exports.updateServiceProject = updateServiceProject;
23
+ exports.diagnoseServiceStatus = diagnoseServiceStatus;
20
24
  const chalk_1 = __importDefault(require("chalk"));
21
25
  const kb_1 = require("./kb");
22
26
  // ── KB Status Mapping ──
23
- const STATUS_MAP = {
27
+ exports.STATUS_MAP = {
24
28
  active: { lifecycle: "ops", status: "active" },
25
29
  hold: { lifecycle: "ops", status: "paused" },
26
30
  maintenance: { lifecycle: "ops", status: "active" },
27
31
  completed: { lifecycle: "sunset", status: "completed" },
28
32
  deprecated: { lifecycle: "sunset", status: "completed" },
29
33
  };
30
- // Keys that map directly to service_projects columns
34
+ // Keys that map directly to services columns
31
35
  const COLUMN_KEYS = {
32
36
  "base-information": "project_name",
33
37
  po: "owner_name",
@@ -66,13 +70,13 @@ async function getUnregisteredServices(pool) {
66
70
  WHERE o.entity_type = 'service'
67
71
  AND o.domain NOT LIKE 'e2e-%'
68
72
  AND NOT EXISTS (
69
- SELECT 1 FROM semo.service_projects sp WHERE sp.service_domain = o.domain
73
+ SELECT 1 FROM semo.services sp WHERE sp.service_domain = o.domain
70
74
  )
71
75
  ORDER BY o.domain`);
72
76
  return result.rows;
73
77
  }
74
78
  async function getRegisteredServices(pool) {
75
- const result = await pool.query(`SELECT service_domain FROM semo.service_projects WHERE service_domain IS NOT NULL`);
79
+ const result = await pool.query(`SELECT service_domain FROM semo.services WHERE service_domain IS NOT NULL`);
76
80
  return result.rows.map((r) => r.service_domain);
77
81
  }
78
82
  async function auditServiceKBEntries(pool, domain, ontologyDescription, ontologyCreatedAt) {
@@ -106,7 +110,7 @@ async function auditServiceKBEntries(pool, domain, ontologyDescription, ontology
106
110
  }
107
111
  for (const [key, items] of byKey) {
108
112
  if (COLUMN_KEYS[key]) {
109
- // Maps to service_projects column
113
+ // Maps to services column
110
114
  const firstContent = items[0]?.content ?? "";
111
115
  audit.mapped.push({
112
116
  key,
@@ -168,7 +172,7 @@ function buildServiceProjectRow(audit) {
168
172
  const rawStatus = statusEntry
169
173
  ? statusEntry.value.split("\n")[0].trim().toLowerCase()
170
174
  : "active";
171
- const mapping = STATUS_MAP[rawStatus] ?? STATUS_MAP["active"];
175
+ const mapping = exports.STATUS_MAP[rawStatus] ?? exports.STATUS_MAP["active"];
172
176
  // Build metadata from repo, slack-channel, tech-stack, etc.
173
177
  const meta = { source: "kb-migration" };
174
178
  for (const m of audit.metadata) {
@@ -185,10 +189,10 @@ function buildServiceProjectRow(audit) {
185
189
  };
186
190
  }
187
191
  async function insertServiceProject(pool, row) {
188
- const result = await pool.query(`INSERT INTO semo.service_projects
192
+ const result = await pool.query(`INSERT INTO semo.services
189
193
  (project_name, owner_name, service_domain, status, lifecycle, launched_at, metadata)
190
194
  VALUES ($1, $2, $3, $4, $5, $6, $7)
191
- RETURNING gfp_id`, [
195
+ RETURNING service_id`, [
192
196
  row.project_name,
193
197
  row.owner_name,
194
198
  row.service_domain,
@@ -197,11 +201,11 @@ async function insertServiceProject(pool, row) {
197
201
  row.launched_at,
198
202
  JSON.stringify(row.metadata),
199
203
  ]);
200
- return result.rows[0].gfp_id;
204
+ return result.rows[0].service_id;
201
205
  }
202
206
  async function writePmSummaryToKB(pool, domain, projectId, row, kbEntryCount) {
203
207
  const content = [
204
- `project_id: ${projectId}`,
208
+ `service_id: ${projectId}`,
205
209
  `project_name: ${row.project_name}`,
206
210
  `lifecycle: ${row.lifecycle}`,
207
211
  `status: ${row.status}`,
@@ -230,7 +234,7 @@ function printAuditReport(results) {
230
234
  chalk_1.default.red("✗");
231
235
  console.log(`\n ${icon} ${chalk_1.default.bold(r.domain)}`);
232
236
  if (r.action === "created") {
233
- console.log(chalk_1.default.gray(` project_id: ${r.projectId}\n` +
237
+ console.log(chalk_1.default.gray(` service_id: ${r.projectId}\n` +
234
238
  ` mapped: ${r.audit.mapped.map((m) => m.key).join(", ") || "(없음)"}\n` +
235
239
  ` metadata: ${r.audit.metadata.map((m) => m.key).join(", ") || "(없음)"}\n` +
236
240
  ` kb-only: ${r.audit.kbOnly.map((k) => `${k.key}(${k.count})`).join(", ") || "(없음)"}`));
@@ -280,3 +284,174 @@ function printDryRunReport(results) {
280
284
  }
281
285
  console.log(chalk_1.default.cyan(` 총 ${results.length}개 서비스 이식 대상\n`));
282
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
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ .next
3
+ dist
4
+ build
5
+ *.tgz
@@ -0,0 +1,7 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": true,
4
+ "trailingComma": "all",
5
+ "printWidth": 100,
6
+ "tabWidth": 2
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semo-cli",
3
- "version": "4.13.0",
3
+ "version": "4.15.1",
4
4
  "description": "SEMO CLI - AI Agent Orchestration Framework Installer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -9,9 +9,10 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc",
12
- "postbuild": "xattr -cr dist/ 2>/dev/null || true",
12
+ "postbuild": "cp -r src/templates dist/templates && xattr -cr dist/ 2>/dev/null || true",
13
13
  "start": "node dist/index.js",
14
- "dev": "ts-node src/index.ts"
14
+ "dev": "ts-node src/index.ts",
15
+ "lint": "eslint src/"
15
16
  },
16
17
  "keywords": [
17
18
  "semo",
@@ -35,11 +36,14 @@
35
36
  "pg": "^8.17.2"
36
37
  },
37
38
  "devDependencies": {
39
+ "@eslint/js": "^9.39.4",
38
40
  "@types/inquirer": "^8.2.0",
39
41
  "@types/node": "^20.0.0",
40
42
  "@types/pg": "^8.16.0",
43
+ "eslint": "^9.39.4",
41
44
  "ts-node": "^10.0.0",
42
- "typescript": "^5.0.0"
45
+ "typescript": "^5.0.0",
46
+ "typescript-eslint": "^8.58.0"
43
47
  },
44
48
  "engines": {
45
49
  "node": ">=18.0.0"