@team-semicolon/semo-cli 4.13.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.
@@ -492,4 +492,160 @@ function registerSessionsCommands(program) {
492
492
  await (0, database_1.closeConnection)();
493
493
  }
494
494
  });
495
+ // ── semo sessions digest ──────────────────────────────────────────────────
496
+ sessionsCmd
497
+ .command("digest")
498
+ .description("세션 transcript에서 미기록 의사결정 추출")
499
+ .option("--transcript <path>", "transcript JSONL 파일 경로")
500
+ .option("--session-dir <dir>", "세션 디렉토리 (최신 transcript 자동 선택)")
501
+ .option("--hours <n>", "최근 N시간 내 transcript만 (session-dir 사용 시)", "24")
502
+ .option("--format <type>", "출력 형식 (table|json)", "table")
503
+ .option("--output <path>", "결과를 파일로 출력")
504
+ .action(async (options) => {
505
+ // transcript 파일 찾기
506
+ let transcripts = [];
507
+ if (options.transcript) {
508
+ if (!fs.existsSync(options.transcript)) {
509
+ console.error(chalk_1.default.red(`❌ 파일 없음: ${options.transcript}`));
510
+ process.exit(1);
511
+ }
512
+ transcripts = [options.transcript];
513
+ }
514
+ else if (options.sessionDir) {
515
+ const dir = options.sessionDir;
516
+ if (!fs.existsSync(dir)) {
517
+ console.error(chalk_1.default.red(`❌ 디렉토리 없음: ${dir}`));
518
+ process.exit(1);
519
+ }
520
+ const hours = parseInt(options.hours) || 24;
521
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
522
+ const files = fs.readdirSync(dir)
523
+ .filter(f => f.endsWith(".jsonl"))
524
+ .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
525
+ .filter(f => f.mtime > cutoff)
526
+ .sort((a, b) => b.mtime - a.mtime);
527
+ transcripts = files.map(f => f.path);
528
+ }
529
+ else {
530
+ // 기본: 현재 semo 프로젝트의 세션 디렉토리
531
+ const semoSessionDir = path.join(os.homedir(), ".claude", "projects", "-Users-reus-Desktop-Sources-semicolon-projects-semo");
532
+ if (fs.existsSync(semoSessionDir)) {
533
+ const hours = parseInt(options.hours) || 24;
534
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
535
+ const files = fs.readdirSync(semoSessionDir)
536
+ .filter(f => f.endsWith(".jsonl"))
537
+ .map(f => ({ name: f, path: path.join(semoSessionDir, f), mtime: fs.statSync(path.join(semoSessionDir, f)).mtimeMs }))
538
+ .filter(f => f.mtime > cutoff)
539
+ .sort((a, b) => b.mtime - a.mtime);
540
+ transcripts = files.map(f => f.path);
541
+ }
542
+ }
543
+ if (transcripts.length === 0) {
544
+ console.log(chalk_1.default.yellow("⚠ 분석할 transcript가 없습니다."));
545
+ process.exit(0);
546
+ }
547
+ // 의사결정 키워드 패턴
548
+ const decisionRe = /(?:도입했|폐기했|전환했|적용했|배포했|마이그레이션|변경했|합의했|결정했|도입합니다|폐기합니다|전환합니다|적용합니다|배포합니다|도입 완료|폐기 완료|전환 완료|적용 완료|배포 완료|표준화했|통합했|분리했|추가했|제거했|Phase \d+ 완료|설정을? 변경|규칙을? 변경|프로세스를? 변경|NON-NEGOTIABLE|신규 생성|전체 배포)/g;
549
+ const kbRecordRe = /KB 기록:|semo kb upsert|KB upsert 완료|답변근거: KB/;
550
+ const candidates = [];
551
+ for (const tPath of transcripts) {
552
+ const lines = fs.readFileSync(tPath, "utf-8").split("\n").filter(l => l.trim());
553
+ let lastKbUpsertLine = -1;
554
+ for (let i = 0; i < lines.length; i++) {
555
+ try {
556
+ const entry = JSON.parse(lines[i]);
557
+ const msg = entry.message ?? entry;
558
+ // KB upsert tool call 추적
559
+ if (msg.role === "assistant") {
560
+ const content = msg.content ?? [];
561
+ for (const block of content) {
562
+ if (block?.type === "tool_use") {
563
+ const inp = JSON.stringify(block.input ?? {});
564
+ if (inp.includes("kb upsert") || inp.includes("kb_upsert")) {
565
+ lastKbUpsertLine = i;
566
+ }
567
+ }
568
+ }
569
+ }
570
+ // tool result에서 upsert 완료 추적
571
+ if (msg.role === "tool") {
572
+ const content = Array.isArray(msg.content)
573
+ ? msg.content.map((c) => typeof c === "string" ? c : c?.text ?? "").join(" ")
574
+ : String(msg.content ?? "");
575
+ if (content.includes("upsert 완료")) {
576
+ lastKbUpsertLine = i;
577
+ }
578
+ }
579
+ // assistant 텍스트에서 의사결정 키워드 탐지
580
+ if (msg.role === "assistant") {
581
+ const content = msg.content ?? [];
582
+ for (const block of content) {
583
+ if (block?.type !== "text")
584
+ continue;
585
+ let text = block.text ?? "";
586
+ // 코드 블록 제거
587
+ text = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]+`/g, "");
588
+ const matches = text.match(decisionRe);
589
+ if (matches && matches.length > 0) {
590
+ const hasKbInText = kbRecordRe.test(text);
591
+ // 같은 턴(±5줄 이내)에 KB upsert가 있었는지
592
+ const hasKbNearby = Math.abs(i - lastKbUpsertLine) <= 10;
593
+ candidates.push({
594
+ file: path.basename(tPath),
595
+ lineNum: i,
596
+ text: text.slice(0, 200).replace(/\n/g, " "),
597
+ keywords: [...new Set(matches)].slice(0, 3),
598
+ hasKbRecord: hasKbInText || hasKbNearby,
599
+ timestamp: entry.timestamp ? new Date(entry.timestamp).toLocaleString("ko-KR") : undefined,
600
+ });
601
+ }
602
+ }
603
+ }
604
+ }
605
+ catch { /* skip */ }
606
+ }
607
+ }
608
+ // 미기록 건만 필터
609
+ const unrecorded = candidates.filter(c => !c.hasKbRecord);
610
+ const recorded = candidates.filter(c => c.hasKbRecord);
611
+ if (options.format === "json") {
612
+ const result = { total: candidates.length, recorded: recorded.length, unrecorded: unrecorded.length, items: unrecorded };
613
+ const out = JSON.stringify(result, null, 2);
614
+ if (options.output) {
615
+ fs.writeFileSync(options.output, out);
616
+ console.log(chalk_1.default.green(`✔ 결과 저장: ${options.output}`));
617
+ }
618
+ else {
619
+ console.log(out);
620
+ }
621
+ }
622
+ else {
623
+ console.log(chalk_1.default.cyan.bold(`\n📋 세션 의사결정 다이제스트\n`));
624
+ console.log(chalk_1.default.gray(` transcript: ${transcripts.length}개 | 총 감지: ${candidates.length}건 | 기록됨: ${recorded.length}건 | 미기록: ${unrecorded.length}건\n`));
625
+ if (unrecorded.length === 0) {
626
+ console.log(chalk_1.default.green(" ✅ 미기록 의사결정 없음\n"));
627
+ }
628
+ else {
629
+ console.log(chalk_1.default.yellow.bold(" ⚠ 미기록 의사결정:\n"));
630
+ for (const c of unrecorded) {
631
+ console.log(chalk_1.default.yellow(` [${c.file}:${c.lineNum}]`) + chalk_1.default.gray(c.timestamp ? ` ${c.timestamp}` : ""));
632
+ console.log(chalk_1.default.white(` 키워드: ${c.keywords.join(", ")}`));
633
+ console.log(chalk_1.default.gray(` "${c.text.slice(0, 120)}..."\n`));
634
+ }
635
+ }
636
+ if (options.output) {
637
+ const lines = [`# 세션 의사결정 다이제스트`, ``, `총 감지: ${candidates.length}건 | 기록됨: ${recorded.length}건 | 미기록: ${unrecorded.length}건`, ``];
638
+ if (unrecorded.length > 0) {
639
+ lines.push(`## 미기록 의사결정`, ``);
640
+ for (const c of unrecorded) {
641
+ lines.push(`- **[${c.file}:${c.lineNum}]** ${c.keywords.join(", ")}`);
642
+ lines.push(` > ${c.text.slice(0, 150)}...`);
643
+ lines.push(``);
644
+ }
645
+ }
646
+ fs.writeFileSync(options.output, lines.join("\n"));
647
+ console.log(chalk_1.default.green(`✔ 결과 저장: ${options.output}`));
648
+ }
649
+ }
650
+ });
495
651
  }
@@ -8,12 +8,13 @@
8
8
  * 스킬 이름은 flat (예: 'kb-manager'), metadata.bot_ids 배열로 봇 매핑.
9
9
  * 동일 스킬명이 여러 봇에 존재하면 bot_ids를 머지.
10
10
  */
11
- import { Pool, PoolClient } from "pg";
11
+ import { Pool, PoolClient } from 'pg';
12
12
  export interface ScannedSkill {
13
13
  name: string;
14
14
  prompt: string;
15
15
  package: string;
16
16
  botId: string;
17
+ referenceFiles?: Record<string, string>;
17
18
  }
18
19
  export interface SkillSyncResult {
19
20
  botSpecific: number;
@@ -49,16 +49,16 @@ exports.syncSkillsToDB = syncSkillsToDB;
49
49
  const fs = __importStar(require("fs"));
50
50
  const path = __importStar(require("path"));
51
51
  function scanSkills(arg) {
52
- if (typeof arg === "string") {
52
+ if (typeof arg === 'string') {
53
53
  return scanSkillsLegacy(arg);
54
54
  }
55
55
  return scanSkillsV2(arg);
56
56
  }
57
57
  function scanSkillsV2(botIds) {
58
58
  const skills = [];
59
- const home = process.env.HOME || "/Users/reus";
59
+ const home = process.env.HOME || '/Users/reus';
60
60
  for (const botId of botIds) {
61
- const skillsDir = path.join(home, `.openclaw-${botId}`, "workspace", "skills");
61
+ const skillsDir = path.join(home, `.openclaw-${botId}`, 'workspace', 'skills');
62
62
  if (!fs.existsSync(skillsDir))
63
63
  continue;
64
64
  let skillEntries;
@@ -71,22 +71,49 @@ function scanSkillsV2(botIds) {
71
71
  for (const skillEntry of skillEntries) {
72
72
  if (!skillEntry.isDirectory())
73
73
  continue;
74
- if (skillEntry.name.endsWith(".skill"))
74
+ if (skillEntry.name.endsWith('.skill'))
75
75
  continue;
76
- if (skillEntry.name.startsWith("_") || skillEntry.name.startsWith("."))
76
+ if (skillEntry.name.startsWith('_') || skillEntry.name.startsWith('.'))
77
77
  continue;
78
- const skillMdPath = path.join(skillsDir, skillEntry.name, "SKILL.md");
78
+ const skillMdPath = path.join(skillsDir, skillEntry.name, 'SKILL.md');
79
79
  if (!fs.existsSync(skillMdPath))
80
80
  continue;
81
81
  try {
82
+ // Scan references/ subdirectory
83
+ let referenceFiles;
84
+ const refsDir = path.join(skillsDir, skillEntry.name, 'references');
85
+ if (fs.existsSync(refsDir)) {
86
+ try {
87
+ const refs = {};
88
+ for (const refFile of fs.readdirSync(refsDir)) {
89
+ const refPath = path.join(refsDir, refFile);
90
+ try {
91
+ if (!fs.statSync(refPath).isFile())
92
+ continue;
93
+ refs[refFile] = fs.readFileSync(refPath, 'utf-8');
94
+ }
95
+ catch {
96
+ /* skip unreadable */
97
+ }
98
+ }
99
+ if (Object.keys(refs).length > 0)
100
+ referenceFiles = refs;
101
+ }
102
+ catch {
103
+ /* skip unreadable refs dir */
104
+ }
105
+ }
82
106
  skills.push({
83
107
  name: skillEntry.name,
84
- prompt: fs.readFileSync(skillMdPath, "utf-8"),
85
- package: "openclaw",
108
+ prompt: fs.readFileSync(skillMdPath, 'utf-8'),
109
+ package: 'openclaw',
86
110
  botId,
111
+ referenceFiles,
87
112
  });
88
113
  }
89
- catch { /* skip unreadable */ }
114
+ catch {
115
+ /* skip unreadable */
116
+ }
90
117
  }
91
118
  }
92
119
  return skills;
@@ -94,34 +121,61 @@ function scanSkillsV2(botIds) {
94
121
  /** Legacy: semo-system/bot-workspaces 에서 스캔 (하위호환) */
95
122
  function scanSkillsLegacy(semoSystemDir) {
96
123
  const skills = [];
97
- const workspacesDir = path.join(semoSystemDir, "bot-workspaces");
124
+ const workspacesDir = path.join(semoSystemDir, 'bot-workspaces');
98
125
  if (!fs.existsSync(workspacesDir))
99
126
  return skills;
100
127
  const botEntries = fs.readdirSync(workspacesDir, { withFileTypes: true });
101
128
  for (const botEntry of botEntries) {
102
129
  if (!botEntry.isDirectory())
103
130
  continue;
104
- const skillsDir = path.join(workspacesDir, botEntry.name, "skills");
131
+ const skillsDir = path.join(workspacesDir, botEntry.name, 'skills');
105
132
  if (!fs.existsSync(skillsDir))
106
133
  continue;
107
134
  const skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true });
108
135
  for (const skillEntry of skillEntries) {
109
136
  if (!skillEntry.isDirectory())
110
137
  continue;
111
- if (skillEntry.name.endsWith(".skill"))
138
+ if (skillEntry.name.endsWith('.skill'))
112
139
  continue;
113
- const skillMdPath = path.join(skillsDir, skillEntry.name, "SKILL.md");
140
+ const skillMdPath = path.join(skillsDir, skillEntry.name, 'SKILL.md');
114
141
  if (!fs.existsSync(skillMdPath))
115
142
  continue;
116
143
  try {
144
+ // Scan references/ subdirectory
145
+ let referenceFiles;
146
+ const refsDir = path.join(skillsDir, skillEntry.name, 'references');
147
+ if (fs.existsSync(refsDir)) {
148
+ try {
149
+ const refs = {};
150
+ for (const refFile of fs.readdirSync(refsDir)) {
151
+ const refPath = path.join(refsDir, refFile);
152
+ try {
153
+ if (!fs.statSync(refPath).isFile())
154
+ continue;
155
+ refs[refFile] = fs.readFileSync(refPath, 'utf-8');
156
+ }
157
+ catch {
158
+ /* skip unreadable */
159
+ }
160
+ }
161
+ if (Object.keys(refs).length > 0)
162
+ referenceFiles = refs;
163
+ }
164
+ catch {
165
+ /* skip unreadable refs dir */
166
+ }
167
+ }
117
168
  skills.push({
118
169
  name: skillEntry.name,
119
- prompt: fs.readFileSync(skillMdPath, "utf-8"),
120
- package: "openclaw",
170
+ prompt: fs.readFileSync(skillMdPath, 'utf-8'),
171
+ package: 'openclaw',
121
172
  botId: botEntry.name,
173
+ referenceFiles,
122
174
  });
123
175
  }
124
- catch { /* skip unreadable */ }
176
+ catch {
177
+ /* skip unreadable */
178
+ }
125
179
  }
126
180
  }
127
181
  return skills;
@@ -131,28 +185,32 @@ function scanSkillsLegacy(semoSystemDir) {
131
185
  */
132
186
  async function getBotIds(pool) {
133
187
  try {
134
- const result = await pool.query("SELECT bot_id FROM semo.bot_status ORDER BY bot_id");
188
+ const result = await pool.query('SELECT bot_id FROM semo.bot_status ORDER BY bot_id');
135
189
  if (result.rows.length > 0) {
136
190
  return result.rows.map((r) => r.bot_id);
137
191
  }
138
192
  }
139
- catch { /* fallback to local scan */ }
193
+ catch {
194
+ /* fallback to local scan */
195
+ }
140
196
  // Fallback: scan ~/.openclaw-*/workspace/ directories
141
- const home = process.env.HOME || "/Users/reus";
197
+ const home = process.env.HOME || '/Users/reus';
142
198
  const botIds = [];
143
199
  try {
144
200
  const entries = fs.readdirSync(home);
145
201
  for (const entry of entries) {
146
202
  const match = entry.match(/^\.openclaw-(.+)$/);
147
203
  if (match) {
148
- const wsDir = path.join(home, entry, "workspace");
204
+ const wsDir = path.join(home, entry, 'workspace');
149
205
  if (fs.existsSync(wsDir)) {
150
206
  botIds.push(match[1]);
151
207
  }
152
208
  }
153
209
  }
154
210
  }
155
- catch { /* skip */ }
211
+ catch {
212
+ /* skip */
213
+ }
156
214
  return botIds.sort();
157
215
  }
158
216
  /**
@@ -163,13 +221,20 @@ async function syncSkillsToDB(client, pool) {
163
221
  const botIds = await getBotIds(pool);
164
222
  const skills = scanSkills(botIds);
165
223
  for (const skill of skills) {
224
+ const metadata = { bot_ids: [skill.botId] };
225
+ if (skill.referenceFiles)
226
+ metadata.reference_files = skill.referenceFiles;
166
227
  await client.query(`INSERT INTO semo.skill_definitions (name, prompt, package, metadata, is_active, office_id)
167
228
  VALUES ($1, $2, $3, $4, true, NULL)
168
229
  ON CONFLICT (name, office_id) DO UPDATE SET
169
230
  prompt = EXCLUDED.prompt,
170
231
  package = EXCLUDED.package,
171
232
  metadata = jsonb_set(
172
- skill_definitions.metadata,
233
+ CASE
234
+ WHEN EXCLUDED.metadata ? 'reference_files'
235
+ THEN jsonb_set(skill_definitions.metadata, '{reference_files}', EXCLUDED.metadata->'reference_files')
236
+ ELSE skill_definitions.metadata
237
+ END,
173
238
  '{bot_ids}',
174
239
  (SELECT jsonb_agg(DISTINCT v)
175
240
  FROM jsonb_array_elements(
@@ -177,7 +242,7 @@ async function syncSkillsToDB(client, pool) {
177
242
  COALESCE(EXCLUDED.metadata->'bot_ids', '[]'::jsonb)
178
243
  ) AS v)
179
244
  ),
180
- updated_at = NOW()`, [skill.name, skill.prompt, skill.package, JSON.stringify({ bot_ids: [skill.botId] })]);
245
+ updated_at = NOW()`, [skill.name, skill.prompt, skill.package, JSON.stringify(metadata)]);
181
246
  }
182
247
  return {
183
248
  botSpecific: skills.length,
@@ -56,8 +56,8 @@ let tmpDir;
56
56
  let passed = 0;
57
57
  let failed = 0;
58
58
  function setup() {
59
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-sync-test-"));
60
- fs.mkdirSync(path.join(dir, "bot-workspaces"), { recursive: true });
59
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-sync-test-'));
60
+ fs.mkdirSync(path.join(dir, 'bot-workspaces'), { recursive: true });
61
61
  return dir;
62
62
  }
63
63
  function cleanup(dir) {
@@ -73,114 +73,147 @@ function assert(condition, message) {
73
73
  console.log(` ❌ ${message}`);
74
74
  }
75
75
  }
76
- function makeBotSkill(dir, botId, skillName, content) {
77
- const skillDir = path.join(dir, "bot-workspaces", botId, "skills", skillName);
76
+ function makeBotSkill(dir, botId, skillName, content, refs) {
77
+ const skillDir = path.join(dir, 'bot-workspaces', botId, 'skills', skillName);
78
78
  fs.mkdirSync(skillDir, { recursive: true });
79
- fs.writeFileSync(path.join(skillDir, "SKILL.md"), content);
79
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
80
+ if (refs) {
81
+ const refsDir = path.join(skillDir, 'references');
82
+ fs.mkdirSync(refsDir, { recursive: true });
83
+ for (const [name, refContent] of Object.entries(refs)) {
84
+ fs.writeFileSync(path.join(refsDir, name), refContent);
85
+ }
86
+ }
80
87
  }
81
88
  // ─── 테스트 ───────────────────────────────────────────────
82
- console.log("\n🧪 skill-sync.test.ts\n");
89
+ console.log('\n🧪 skill-sync.test.ts\n');
83
90
  // 1. 빈 디렉토리
84
- console.log("Case 1: 빈 디렉토리");
91
+ console.log('Case 1: 빈 디렉토리');
85
92
  tmpDir = setup();
86
93
  {
87
94
  const skills = (0, skill_sync_1.scanSkills)(tmpDir);
88
- assert(skills.length === 0, "skills = 0");
95
+ assert(skills.length === 0, 'skills = 0');
89
96
  }
90
97
  cleanup(tmpDir);
91
98
  // 2. 봇 전용 스킬 — flat name
92
- console.log("Case 2: 봇 전용 스킬 flat name");
99
+ console.log('Case 2: 봇 전용 스킬 flat name');
93
100
  tmpDir = setup();
94
101
  {
95
- makeBotSkill(tmpDir, "semiclaw", "github-issue-pipeline", "# GH Pipeline");
102
+ makeBotSkill(tmpDir, 'semiclaw', 'github-issue-pipeline', '# GH Pipeline');
96
103
  const skills = (0, skill_sync_1.scanSkills)(tmpDir);
97
104
  assert(skills.length === 1, `skills = 1 (got ${skills.length})`);
98
- assert(skills[0].name === "github-issue-pipeline", `name = github-issue-pipeline (got ${skills[0].name})`);
99
- assert(skills[0].botId === "semiclaw", "botId = semiclaw");
100
- assert(skills[0].package === "openclaw", "package = openclaw");
105
+ assert(skills[0].name === 'github-issue-pipeline', `name = github-issue-pipeline (got ${skills[0].name})`);
106
+ assert(skills[0].botId === 'semiclaw', 'botId = semiclaw');
107
+ assert(skills[0].package === 'openclaw', 'package = openclaw');
101
108
  }
102
109
  cleanup(tmpDir);
103
110
  // 3. 복수 봇의 전용 스킬 — botId 정확성
104
- console.log("Case 3: 복수 봇 botId 정확성");
111
+ console.log('Case 3: 복수 봇 botId 정확성');
105
112
  tmpDir = setup();
106
113
  {
107
- makeBotSkill(tmpDir, "semiclaw", "skill-a", "# A");
108
- makeBotSkill(tmpDir, "semiclaw", "skill-b", "# B");
109
- makeBotSkill(tmpDir, "workclaw", "skill-c", "# C");
110
- makeBotSkill(tmpDir, "infraclaw", "skill-d", "# D");
114
+ makeBotSkill(tmpDir, 'semiclaw', 'skill-a', '# A');
115
+ makeBotSkill(tmpDir, 'semiclaw', 'skill-b', '# B');
116
+ makeBotSkill(tmpDir, 'workclaw', 'skill-c', '# C');
117
+ makeBotSkill(tmpDir, 'infraclaw', 'skill-d', '# D');
111
118
  const skills = (0, skill_sync_1.scanSkills)(tmpDir);
112
119
  assert(skills.length === 4, `총 4개 (got ${skills.length})`);
113
- const semiclawSkills = skills.filter((s) => s.botId === "semiclaw");
114
- const workclawSkills = skills.filter((s) => s.botId === "workclaw");
115
- const infraclawSkills = skills.filter((s) => s.botId === "infraclaw");
120
+ const semiclawSkills = skills.filter((s) => s.botId === 'semiclaw');
121
+ const workclawSkills = skills.filter((s) => s.botId === 'workclaw');
122
+ const infraclawSkills = skills.filter((s) => s.botId === 'infraclaw');
116
123
  assert(semiclawSkills.length === 2, `semiclaw: 2개 (got ${semiclawSkills.length})`);
117
124
  assert(workclawSkills.length === 1, `workclaw: 1개 (got ${workclawSkills.length})`);
118
125
  assert(infraclawSkills.length === 1, `infraclaw: 1개 (got ${infraclawSkills.length})`);
119
126
  }
120
127
  cleanup(tmpDir);
121
128
  // 4. .skill 확장자 디렉토리 → 스킵
122
- console.log("Case 4: .skill 확장자 디렉토리");
129
+ console.log('Case 4: .skill 확장자 디렉토리');
123
130
  tmpDir = setup();
124
131
  {
125
- const dotSkillDir = path.join(tmpDir, "bot-workspaces", "semiclaw", "skills", "old-format.skill");
132
+ const dotSkillDir = path.join(tmpDir, 'bot-workspaces', 'semiclaw', 'skills', 'old-format.skill');
126
133
  fs.mkdirSync(dotSkillDir, { recursive: true });
127
- fs.writeFileSync(path.join(dotSkillDir, "SKILL.md"), "should be skipped");
128
- makeBotSkill(tmpDir, "semiclaw", "valid-skill", "# Valid");
134
+ fs.writeFileSync(path.join(dotSkillDir, 'SKILL.md'), 'should be skipped');
135
+ makeBotSkill(tmpDir, 'semiclaw', 'valid-skill', '# Valid');
129
136
  const skills = (0, skill_sync_1.scanSkills)(tmpDir);
130
137
  assert(skills.length === 1, `.skill 디렉토리 스킵, skills = 1 (got ${skills.length})`);
131
- assert(skills[0].name === "valid-skill", "올바른 스킬만 반환");
138
+ assert(skills[0].name === 'valid-skill', '올바른 스킬만 반환');
132
139
  }
133
140
  cleanup(tmpDir);
134
141
  // 5. SKILL.md 없는 디렉토리 → 스킵
135
- console.log("Case 5: SKILL.md 없는 디렉토리");
142
+ console.log('Case 5: SKILL.md 없는 디렉토리');
136
143
  tmpDir = setup();
137
144
  {
138
- const emptyDir = path.join(tmpDir, "bot-workspaces", "semiclaw", "skills", "no-skill-md");
145
+ const emptyDir = path.join(tmpDir, 'bot-workspaces', 'semiclaw', 'skills', 'no-skill-md');
139
146
  fs.mkdirSync(emptyDir, { recursive: true });
140
- makeBotSkill(tmpDir, "semiclaw", "valid-skill", "content");
147
+ makeBotSkill(tmpDir, 'semiclaw', 'valid-skill', 'content');
141
148
  const skills = (0, skill_sync_1.scanSkills)(tmpDir);
142
149
  assert(skills.length === 1, `SKILL.md 없는 디렉토리 스킵, skills = 1 (got ${skills.length})`);
143
150
  }
144
151
  cleanup(tmpDir);
145
152
  // 6. 스킬 변경 감지 — 파일 수정 후 재스캔
146
- console.log("Case 6: 스킬 변경 감지");
153
+ console.log('Case 6: 스킬 변경 감지');
147
154
  tmpDir = setup();
148
155
  {
149
- makeBotSkill(tmpDir, "semiclaw", "review", "# v1 content");
156
+ makeBotSkill(tmpDir, 'semiclaw', 'review', '# v1 content');
150
157
  const scan1 = (0, skill_sync_1.scanSkills)(tmpDir);
151
- assert(scan1[0].prompt === "# v1 content", "초기 스캔: v1");
152
- fs.writeFileSync(path.join(tmpDir, "bot-workspaces", "semiclaw", "skills", "review", "SKILL.md"), "# v2 updated content");
158
+ assert(scan1[0].prompt === '# v1 content', '초기 스캔: v1');
159
+ fs.writeFileSync(path.join(tmpDir, 'bot-workspaces', 'semiclaw', 'skills', 'review', 'SKILL.md'), '# v2 updated content');
153
160
  const scan2 = (0, skill_sync_1.scanSkills)(tmpDir);
154
- assert(scan2[0].prompt === "# v2 updated content", "재스캔: v2 반영됨");
161
+ assert(scan2[0].prompt === '# v2 updated content', '재스캔: v2 반영됨');
155
162
  }
156
163
  cleanup(tmpDir);
157
164
  // 7. 스킬 추가 감지 — 새 디렉토리 추가 후 재스캔
158
- console.log("Case 7: 스킬 추가 감지");
165
+ console.log('Case 7: 스킬 추가 감지');
159
166
  tmpDir = setup();
160
167
  {
161
- makeBotSkill(tmpDir, "semiclaw", "existing", "# Existing");
168
+ makeBotSkill(tmpDir, 'semiclaw', 'existing', '# Existing');
162
169
  const scan1 = (0, skill_sync_1.scanSkills)(tmpDir);
163
- assert(scan1.length === 1, "초기: 1개");
164
- makeBotSkill(tmpDir, "semiclaw", "new-skill", "# New Skill");
170
+ assert(scan1.length === 1, '초기: 1개');
171
+ makeBotSkill(tmpDir, 'semiclaw', 'new-skill', '# New Skill');
165
172
  const scan2 = (0, skill_sync_1.scanSkills)(tmpDir);
166
173
  assert(scan2.length === 2, `추가 후: 2개 (got ${scan2.length})`);
167
174
  }
168
175
  cleanup(tmpDir);
169
176
  // 8. 스킬 삭제 감지 — SKILL.md 삭제 후 재스캔
170
- console.log("Case 8: 스킬 삭제 감지");
177
+ console.log('Case 8: 스킬 삭제 감지');
171
178
  tmpDir = setup();
172
179
  {
173
- makeBotSkill(tmpDir, "semiclaw", "to-delete", "# Will be deleted");
174
- makeBotSkill(tmpDir, "semiclaw", "keep", "# Keep");
180
+ makeBotSkill(tmpDir, 'semiclaw', 'to-delete', '# Will be deleted');
181
+ makeBotSkill(tmpDir, 'semiclaw', 'keep', '# Keep');
175
182
  const scan1 = (0, skill_sync_1.scanSkills)(tmpDir);
176
- assert(scan1.length === 2, "초기: 2개");
177
- fs.unlinkSync(path.join(tmpDir, "bot-workspaces", "semiclaw", "skills", "to-delete", "SKILL.md"));
183
+ assert(scan1.length === 2, '초기: 2개');
184
+ fs.unlinkSync(path.join(tmpDir, 'bot-workspaces', 'semiclaw', 'skills', 'to-delete', 'SKILL.md'));
178
185
  const scan2 = (0, skill_sync_1.scanSkills)(tmpDir);
179
186
  assert(scan2.length === 1, `삭제 후: 1개 (got ${scan2.length})`);
180
- assert(scan2[0].name === "keep", "남은 스킬: keep");
187
+ assert(scan2[0].name === 'keep', '남은 스킬: keep');
188
+ }
189
+ cleanup(tmpDir);
190
+ // 9. references/ 있는 스킬 → referenceFiles 맵 반환
191
+ console.log('Case 9: references/ 있는 스킬');
192
+ tmpDir = setup();
193
+ {
194
+ makeBotSkill(tmpDir, 'semiclaw', 'adhoc-meeting', '# Adhoc Meeting', {
195
+ 'meeting-template.md': '# Template',
196
+ 'decision-template.md': '# Decision',
197
+ });
198
+ const skills = (0, skill_sync_1.scanSkills)(tmpDir);
199
+ assert(skills.length === 1, 'skills = 1');
200
+ assert(skills[0].referenceFiles !== undefined, 'referenceFiles 존재');
201
+ assert(Object.keys(skills[0].referenceFiles).length === 2, 'referenceFiles 2개');
202
+ assert(skills[0].referenceFiles['meeting-template.md'] === '# Template', 'meeting-template 내용 일치');
203
+ assert(skills[0].referenceFiles['decision-template.md'] === '# Decision', 'decision-template 내용 일치');
204
+ }
205
+ cleanup(tmpDir);
206
+ // 10. references/ 없는 스킬 → referenceFiles undefined
207
+ console.log('Case 10: references/ 없는 스킬');
208
+ tmpDir = setup();
209
+ {
210
+ makeBotSkill(tmpDir, 'semiclaw', 'simple-skill', '# Simple');
211
+ const skills = (0, skill_sync_1.scanSkills)(tmpDir);
212
+ assert(skills.length === 1, 'skills = 1');
213
+ assert(skills[0].referenceFiles === undefined, 'referenceFiles undefined');
181
214
  }
182
215
  cleanup(tmpDir);
183
216
  // ─── 결과 ──────────────────────────────────────────────────
184
- console.log(`\n${"".repeat(40)}`);
217
+ console.log(`\n${''.repeat(40)}`);
185
218
  console.log(`총 ${passed + failed}개 테스트: ✅ ${passed} passed, ❌ ${failed} failed\n`);
186
219
  process.exit(failed > 0 ? 1 : 0);
@@ -11,7 +11,7 @@
11
11
  * - command_definitions (prompt as content)
12
12
  * - semo.skills / semo.agents / semo.commands 는 하위 호환 뷰
13
13
  */
14
- import { Pool } from "pg";
14
+ import { Pool } from 'pg';
15
15
  export declare function getPool(): Pool;
16
16
  export interface Skill {
17
17
  id: string;
@@ -20,6 +20,7 @@ export interface Skill {
20
20
  description: string | null;
21
21
  content: string;
22
22
  bot_ids: string[];
23
+ reference_files?: Record<string, string>;
23
24
  category: string;
24
25
  package: string;
25
26
  is_active: boolean;