@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.
- package/dist/commands/context.js +23 -3
- package/dist/commands/harness.d.ts +8 -0
- package/dist/commands/harness.js +412 -0
- package/dist/commands/incubator.d.ts +10 -0
- package/dist/commands/incubator.js +517 -0
- package/dist/commands/service.js +144 -3
- package/dist/commands/sessions.js +156 -0
- package/dist/commands/skill-sync.d.ts +2 -1
- package/dist/commands/skill-sync.js +88 -23
- package/dist/commands/skill-sync.test.js +78 -45
- package/dist/database.d.ts +2 -1
- package/dist/database.js +109 -27
- package/dist/global-cache.js +73 -30
- package/dist/index.js +576 -522
- package/dist/kb.d.ts +4 -4
- package/dist/kb.js +203 -103
- package/dist/semo-workspace.js +51 -0
- package/dist/service-migrate.d.ts +47 -2
- package/dist/service-migrate.js +188 -13
- package/dist/templates/harness/commit-msg +1 -0
- package/dist/templates/harness/commitlint.config.js +11 -0
- package/dist/templates/harness/eslint.config.mjs +17 -0
- package/dist/templates/harness/pr-quality-gate.yml +19 -0
- package/dist/templates/harness/pre-commit +1 -0
- package/dist/templates/harness/pre-push +1 -0
- package/dist/templates/harness/prettierignore +5 -0
- package/dist/templates/harness/prettierrc.json +7 -0
- package/package.json +8 -4
|
@@ -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
|
|
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 ===
|
|
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 ||
|
|
59
|
+
const home = process.env.HOME || '/Users/reus';
|
|
60
60
|
for (const botId of botIds) {
|
|
61
|
-
const skillsDir = path.join(home, `.openclaw-${botId}`,
|
|
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(
|
|
74
|
+
if (skillEntry.name.endsWith('.skill'))
|
|
75
75
|
continue;
|
|
76
|
-
if (skillEntry.name.startsWith(
|
|
76
|
+
if (skillEntry.name.startsWith('_') || skillEntry.name.startsWith('.'))
|
|
77
77
|
continue;
|
|
78
|
-
const skillMdPath = path.join(skillsDir, skillEntry.name,
|
|
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,
|
|
85
|
-
package:
|
|
108
|
+
prompt: fs.readFileSync(skillMdPath, 'utf-8'),
|
|
109
|
+
package: 'openclaw',
|
|
86
110
|
botId,
|
|
111
|
+
referenceFiles,
|
|
87
112
|
});
|
|
88
113
|
}
|
|
89
|
-
catch {
|
|
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,
|
|
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,
|
|
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(
|
|
138
|
+
if (skillEntry.name.endsWith('.skill'))
|
|
112
139
|
continue;
|
|
113
|
-
const skillMdPath = path.join(skillsDir, skillEntry.name,
|
|
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,
|
|
120
|
-
package:
|
|
170
|
+
prompt: fs.readFileSync(skillMdPath, 'utf-8'),
|
|
171
|
+
package: 'openclaw',
|
|
121
172
|
botId: botEntry.name,
|
|
173
|
+
referenceFiles,
|
|
122
174
|
});
|
|
123
175
|
}
|
|
124
|
-
catch {
|
|
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(
|
|
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 {
|
|
193
|
+
catch {
|
|
194
|
+
/* fallback to local scan */
|
|
195
|
+
}
|
|
140
196
|
// Fallback: scan ~/.openclaw-*/workspace/ directories
|
|
141
|
-
const home = process.env.HOME ||
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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(),
|
|
60
|
-
fs.mkdirSync(path.join(dir,
|
|
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,
|
|
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,
|
|
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(
|
|
89
|
+
console.log('\n🧪 skill-sync.test.ts\n');
|
|
83
90
|
// 1. 빈 디렉토리
|
|
84
|
-
console.log(
|
|
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,
|
|
95
|
+
assert(skills.length === 0, 'skills = 0');
|
|
89
96
|
}
|
|
90
97
|
cleanup(tmpDir);
|
|
91
98
|
// 2. 봇 전용 스킬 — flat name
|
|
92
|
-
console.log(
|
|
99
|
+
console.log('Case 2: 봇 전용 스킬 flat name');
|
|
93
100
|
tmpDir = setup();
|
|
94
101
|
{
|
|
95
|
-
makeBotSkill(tmpDir,
|
|
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 ===
|
|
99
|
-
assert(skills[0].botId ===
|
|
100
|
-
assert(skills[0].package ===
|
|
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(
|
|
111
|
+
console.log('Case 3: 복수 봇 botId 정확성');
|
|
105
112
|
tmpDir = setup();
|
|
106
113
|
{
|
|
107
|
-
makeBotSkill(tmpDir,
|
|
108
|
-
makeBotSkill(tmpDir,
|
|
109
|
-
makeBotSkill(tmpDir,
|
|
110
|
-
makeBotSkill(tmpDir,
|
|
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 ===
|
|
114
|
-
const workclawSkills = skills.filter((s) => s.botId ===
|
|
115
|
-
const infraclawSkills = skills.filter((s) => s.botId ===
|
|
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(
|
|
129
|
+
console.log('Case 4: .skill 확장자 디렉토리');
|
|
123
130
|
tmpDir = setup();
|
|
124
131
|
{
|
|
125
|
-
const dotSkillDir = path.join(tmpDir,
|
|
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,
|
|
128
|
-
makeBotSkill(tmpDir,
|
|
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 ===
|
|
138
|
+
assert(skills[0].name === 'valid-skill', '올바른 스킬만 반환');
|
|
132
139
|
}
|
|
133
140
|
cleanup(tmpDir);
|
|
134
141
|
// 5. SKILL.md 없는 디렉토리 → 스킵
|
|
135
|
-
console.log(
|
|
142
|
+
console.log('Case 5: SKILL.md 없는 디렉토리');
|
|
136
143
|
tmpDir = setup();
|
|
137
144
|
{
|
|
138
|
-
const emptyDir = path.join(tmpDir,
|
|
145
|
+
const emptyDir = path.join(tmpDir, 'bot-workspaces', 'semiclaw', 'skills', 'no-skill-md');
|
|
139
146
|
fs.mkdirSync(emptyDir, { recursive: true });
|
|
140
|
-
makeBotSkill(tmpDir,
|
|
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(
|
|
153
|
+
console.log('Case 6: 스킬 변경 감지');
|
|
147
154
|
tmpDir = setup();
|
|
148
155
|
{
|
|
149
|
-
makeBotSkill(tmpDir,
|
|
156
|
+
makeBotSkill(tmpDir, 'semiclaw', 'review', '# v1 content');
|
|
150
157
|
const scan1 = (0, skill_sync_1.scanSkills)(tmpDir);
|
|
151
|
-
assert(scan1[0].prompt ===
|
|
152
|
-
fs.writeFileSync(path.join(tmpDir,
|
|
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 ===
|
|
161
|
+
assert(scan2[0].prompt === '# v2 updated content', '재스캔: v2 반영됨');
|
|
155
162
|
}
|
|
156
163
|
cleanup(tmpDir);
|
|
157
164
|
// 7. 스킬 추가 감지 — 새 디렉토리 추가 후 재스캔
|
|
158
|
-
console.log(
|
|
165
|
+
console.log('Case 7: 스킬 추가 감지');
|
|
159
166
|
tmpDir = setup();
|
|
160
167
|
{
|
|
161
|
-
makeBotSkill(tmpDir,
|
|
168
|
+
makeBotSkill(tmpDir, 'semiclaw', 'existing', '# Existing');
|
|
162
169
|
const scan1 = (0, skill_sync_1.scanSkills)(tmpDir);
|
|
163
|
-
assert(scan1.length === 1,
|
|
164
|
-
makeBotSkill(tmpDir,
|
|
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(
|
|
177
|
+
console.log('Case 8: 스킬 삭제 감지');
|
|
171
178
|
tmpDir = setup();
|
|
172
179
|
{
|
|
173
|
-
makeBotSkill(tmpDir,
|
|
174
|
-
makeBotSkill(tmpDir,
|
|
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,
|
|
177
|
-
fs.unlinkSync(path.join(tmpDir,
|
|
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 ===
|
|
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${
|
|
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);
|
package/dist/database.d.ts
CHANGED
|
@@ -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
|
|
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;
|