@team-semicolon/semo-cli 4.15.0 → 4.16.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.
- package/dist/commands/agent-flush.d.ts +13 -0
- package/dist/commands/agent-flush.js +75 -0
- package/dist/commands/commitments.d.ts +1 -1
- package/dist/commands/commitments.js +216 -72
- package/dist/commands/context-preserve.d.ts +8 -0
- package/dist/commands/context-preserve.js +127 -0
- package/dist/commands/incubator.js +6 -14
- package/dist/global-cache.js +47 -16
- package/dist/index.js +18 -0
- package/dist/kb.d.ts +1 -0
- package/dist/kb.js +38 -14
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semo agent-flush — 서브에이전트 종료 시 KB/commitments flush
|
|
3
|
+
*
|
|
4
|
+
* SubagentStop 훅에서 호출. stdin으로 session context를 받아:
|
|
5
|
+
* 1. 해당 세션의 active commitment → done 처리
|
|
6
|
+
* 2. bot_sessions 상태 → terminated
|
|
7
|
+
*
|
|
8
|
+
* 사용법:
|
|
9
|
+
* npx semo agent-flush --bot semiclaw
|
|
10
|
+
* (stdin으로 { session_id, transcript_path } JSON 수신)
|
|
11
|
+
*/
|
|
12
|
+
import { Command } from 'commander';
|
|
13
|
+
export declare function registerAgentFlushCommands(program: Command): void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* semo agent-flush — 서브에이전트 종료 시 KB/commitments flush
|
|
4
|
+
*
|
|
5
|
+
* SubagentStop 훅에서 호출. stdin으로 session context를 받아:
|
|
6
|
+
* 1. 해당 세션의 active commitment → done 처리
|
|
7
|
+
* 2. bot_sessions 상태 → terminated
|
|
8
|
+
*
|
|
9
|
+
* 사용법:
|
|
10
|
+
* npx semo agent-flush --bot semiclaw
|
|
11
|
+
* (stdin으로 { session_id, transcript_path } JSON 수신)
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.registerAgentFlushCommands = registerAgentFlushCommands;
|
|
15
|
+
const database_1 = require("../database");
|
|
16
|
+
function registerAgentFlushCommands(program) {
|
|
17
|
+
program
|
|
18
|
+
.command('agent-flush')
|
|
19
|
+
.description('서브에이전트 종료 시 commitment/session flush')
|
|
20
|
+
.requiredOption('--bot <botId>', '봇 ID')
|
|
21
|
+
.action(async (options) => {
|
|
22
|
+
const { bot: botId } = options;
|
|
23
|
+
// stdin에서 session context 읽기 (SubagentStop 훅이 전달)
|
|
24
|
+
let sessionId = '';
|
|
25
|
+
try {
|
|
26
|
+
const input = await readStdin();
|
|
27
|
+
if (input) {
|
|
28
|
+
const parsed = JSON.parse(input);
|
|
29
|
+
sessionId = parsed.session_id || '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// stdin 없거나 파싱 실패 — 무시하고 진행
|
|
34
|
+
}
|
|
35
|
+
const connected = await (0, database_1.isDbConnected)();
|
|
36
|
+
if (!connected) {
|
|
37
|
+
process.exit(0); // DB 없으면 조용히 종료 (훅이 세션 블로킹하지 않도록)
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const pool = (0, database_1.getPool)();
|
|
41
|
+
// 1. 해당 봇의 active/pending commitment → done 처리
|
|
42
|
+
const commitResult = await pool.query(`UPDATE semo.bot_commitments
|
|
43
|
+
SET status = 'done'
|
|
44
|
+
WHERE bot_id = $1
|
|
45
|
+
AND status IN ('pending', 'active')
|
|
46
|
+
AND ($2 = '' OR assigned_session = $2)
|
|
47
|
+
RETURNING id, title`, [botId, sessionId]);
|
|
48
|
+
for (const row of commitResult.rows) {
|
|
49
|
+
process.stderr.write(`[agent-flush] commitment done: ${row.id} — ${row.title}\n`);
|
|
50
|
+
}
|
|
51
|
+
// 2. bot_sessions 상태 → terminated
|
|
52
|
+
if (sessionId) {
|
|
53
|
+
await pool.query(`UPDATE semo.bot_sessions
|
|
54
|
+
SET status = 'terminated', ended_at = NOW()
|
|
55
|
+
WHERE session_key = $1 AND bot_id = $2`, [sessionId, botId]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
process.stderr.write(`[agent-flush] error: ${err}\n`);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await (0, database_1.closeConnection)();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function readStdin() {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let data = '';
|
|
69
|
+
process.stdin.setEncoding('utf8');
|
|
70
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
71
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
72
|
+
// stdin이 즉시 닫혀있으면 빈 문자열 반환
|
|
73
|
+
setTimeout(() => resolve(data.trim()), 500);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -24,11 +24,11 @@ function parseDeadline(input) {
|
|
|
24
24
|
const value = parseInt(match[1]);
|
|
25
25
|
const unit = match[2];
|
|
26
26
|
const now = new Date();
|
|
27
|
-
if (unit ===
|
|
27
|
+
if (unit === 'm')
|
|
28
28
|
now.setMinutes(now.getMinutes() + value);
|
|
29
|
-
else if (unit ===
|
|
29
|
+
else if (unit === 'h')
|
|
30
30
|
now.setHours(now.getHours() + value);
|
|
31
|
-
else if (unit ===
|
|
31
|
+
else if (unit === 'd')
|
|
32
32
|
now.setDate(now.getDate() + value);
|
|
33
33
|
return now.toISOString();
|
|
34
34
|
}
|
|
@@ -38,23 +38,23 @@ function parseDeadline(input) {
|
|
|
38
38
|
// ─── Command registration ───────────────────────────────────────────────────
|
|
39
39
|
function registerCommitmentsCommands(program) {
|
|
40
40
|
const cmd = program
|
|
41
|
-
.command(
|
|
42
|
-
.description(
|
|
41
|
+
.command('commitments')
|
|
42
|
+
.description('봇 약속 추적 (Durable Commitment Tracking)');
|
|
43
43
|
// ── semo commitments create ────────────────────────────────────────────────
|
|
44
44
|
cmd
|
|
45
|
-
.command(
|
|
46
|
-
.description(
|
|
47
|
-
.requiredOption(
|
|
48
|
-
.requiredOption(
|
|
49
|
-
.option(
|
|
50
|
-
.option(
|
|
51
|
-
.option(
|
|
52
|
-
.option(
|
|
53
|
-
.option(
|
|
45
|
+
.command('create')
|
|
46
|
+
.description('새 약속 등록')
|
|
47
|
+
.requiredOption('--bot-id <id>', '봇 ID')
|
|
48
|
+
.requiredOption('--title <text>', '약속 제목')
|
|
49
|
+
.option('--description <text>', '상세 설명')
|
|
50
|
+
.option('--source-type <type>', '출처 (github-issue|slack|cron|manual)')
|
|
51
|
+
.option('--source-ref <ref>', '출처 참조 (issue URL, channel:thread)')
|
|
52
|
+
.option('--deadline <time>', '기한 (15m, 1h, 2d, 또는 ISO 문자열)')
|
|
53
|
+
.option('--steps <json>', '단계 JSON 배열 (예: \'[{"label":"Step1","done":false}]\')')
|
|
54
54
|
.action(async (options) => {
|
|
55
55
|
const connected = await (0, database_1.isDbConnected)();
|
|
56
56
|
if (!connected) {
|
|
57
|
-
console.error(chalk_1.default.red(
|
|
57
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
58
58
|
await (0, database_1.closeConnection)();
|
|
59
59
|
process.exit(1);
|
|
60
60
|
}
|
|
@@ -66,7 +66,7 @@ function registerCommitmentsCommands(program) {
|
|
|
66
66
|
steps = JSON.parse(options.steps);
|
|
67
67
|
}
|
|
68
68
|
catch {
|
|
69
|
-
console.error(chalk_1.default.red(
|
|
69
|
+
console.error(chalk_1.default.red('❌ --steps JSON 파싱 실패'));
|
|
70
70
|
await (0, database_1.closeConnection)();
|
|
71
71
|
process.exit(1);
|
|
72
72
|
}
|
|
@@ -89,7 +89,12 @@ function registerCommitmentsCommands(program) {
|
|
|
89
89
|
if (!options.sourceType) {
|
|
90
90
|
console.log(chalk_1.default.yellow(`⚠ source-type 미지정: 자동 검증 불가. --source-type 권장.`));
|
|
91
91
|
}
|
|
92
|
-
console.log(JSON.stringify({
|
|
92
|
+
console.log(JSON.stringify({
|
|
93
|
+
id,
|
|
94
|
+
bot_id: options.botId,
|
|
95
|
+
title: options.title,
|
|
96
|
+
deadline_at: deadlineAt,
|
|
97
|
+
}));
|
|
93
98
|
}
|
|
94
99
|
catch (err) {
|
|
95
100
|
console.error(chalk_1.default.red(`❌ create 실패: ${err}`));
|
|
@@ -101,15 +106,15 @@ function registerCommitmentsCommands(program) {
|
|
|
101
106
|
});
|
|
102
107
|
// ── semo commitments update ────────────────────────────────────────────────
|
|
103
108
|
cmd
|
|
104
|
-
.command(
|
|
105
|
-
.description(
|
|
106
|
-
.option(
|
|
107
|
-
.option(
|
|
108
|
-
.option(
|
|
109
|
+
.command('update <id>')
|
|
110
|
+
.description('진행 보고 (heartbeat, status, step 완료)')
|
|
111
|
+
.option('--heartbeat', 'heartbeat 갱신')
|
|
112
|
+
.option('--status <status>', '상태 변경 (active|pending)')
|
|
113
|
+
.option('--step-done <label>', '특정 step을 완료 처리')
|
|
109
114
|
.action(async (id, options) => {
|
|
110
115
|
const connected = await (0, database_1.isDbConnected)();
|
|
111
116
|
if (!connected) {
|
|
112
|
-
console.error(chalk_1.default.red(
|
|
117
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
113
118
|
await (0, database_1.closeConnection)();
|
|
114
119
|
process.exit(1);
|
|
115
120
|
}
|
|
@@ -123,7 +128,10 @@ function registerCommitmentsCommands(program) {
|
|
|
123
128
|
console.log(chalk_1.default.green(`✔ heartbeat updated: ${id}`));
|
|
124
129
|
}
|
|
125
130
|
if (options.status) {
|
|
126
|
-
await pool.query(`UPDATE semo.bot_commitments SET status = $1 WHERE id = $2`, [
|
|
131
|
+
await pool.query(`UPDATE semo.bot_commitments SET status = $1 WHERE id = $2`, [
|
|
132
|
+
options.status,
|
|
133
|
+
id,
|
|
134
|
+
]);
|
|
127
135
|
console.log(chalk_1.default.green(`✔ status → ${options.status}: ${id}`));
|
|
128
136
|
}
|
|
129
137
|
if (options.stepDone) {
|
|
@@ -151,12 +159,12 @@ function registerCommitmentsCommands(program) {
|
|
|
151
159
|
});
|
|
152
160
|
// ── semo commitments done ──────────────────────────────────────────────────
|
|
153
161
|
cmd
|
|
154
|
-
.command(
|
|
155
|
-
.description(
|
|
162
|
+
.command('done <id>')
|
|
163
|
+
.description('약속 완료 처리')
|
|
156
164
|
.action(async (id) => {
|
|
157
165
|
const connected = await (0, database_1.isDbConnected)();
|
|
158
166
|
if (!connected) {
|
|
159
|
-
console.error(chalk_1.default.red(
|
|
167
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
160
168
|
await (0, database_1.closeConnection)();
|
|
161
169
|
process.exit(1);
|
|
162
170
|
}
|
|
@@ -180,13 +188,13 @@ function registerCommitmentsCommands(program) {
|
|
|
180
188
|
});
|
|
181
189
|
// ── semo commitments fail ──────────────────────────────────────────────────
|
|
182
190
|
cmd
|
|
183
|
-
.command(
|
|
184
|
-
.description(
|
|
185
|
-
.option(
|
|
191
|
+
.command('fail <id>')
|
|
192
|
+
.description('약속 실패 처리')
|
|
193
|
+
.option('--reason <text>', '실패 사유')
|
|
186
194
|
.action(async (id, options) => {
|
|
187
195
|
const connected = await (0, database_1.isDbConnected)();
|
|
188
196
|
if (!connected) {
|
|
189
|
-
console.error(chalk_1.default.red(
|
|
197
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
190
198
|
await (0, database_1.closeConnection)();
|
|
191
199
|
process.exit(1);
|
|
192
200
|
}
|
|
@@ -194,7 +202,7 @@ function registerCommitmentsCommands(program) {
|
|
|
194
202
|
const pool = (0, database_1.getPool)();
|
|
195
203
|
const metadataUpdate = options.reason
|
|
196
204
|
? `, metadata = metadata || jsonb_build_object('fail_reason', $2::text)`
|
|
197
|
-
:
|
|
205
|
+
: '';
|
|
198
206
|
const params = options.reason ? [id, options.reason] : [id];
|
|
199
207
|
const result = await pool.query(`UPDATE semo.bot_commitments SET status = 'failed'${metadataUpdate} WHERE id = $1 AND status IN ('pending', 'active') RETURNING id`, params);
|
|
200
208
|
if (result.rowCount === 0) {
|
|
@@ -214,16 +222,16 @@ function registerCommitmentsCommands(program) {
|
|
|
214
222
|
});
|
|
215
223
|
// ── semo commitments list ──────────────────────────────────────────────────
|
|
216
224
|
cmd
|
|
217
|
-
.command(
|
|
218
|
-
.description(
|
|
219
|
-
.option(
|
|
220
|
-
.option(
|
|
221
|
-
.option(
|
|
222
|
-
.option(
|
|
225
|
+
.command('list')
|
|
226
|
+
.description('약속 목록 조회')
|
|
227
|
+
.option('--bot-id <id>', '특정 봇만')
|
|
228
|
+
.option('--status <status>', '상태 필터 (pending|active|done|failed|expired)')
|
|
229
|
+
.option('--limit <n>', '최대 조회 수', '20')
|
|
230
|
+
.option('--format <type>', '출력 형식 (table|json)', 'table')
|
|
223
231
|
.action(async (options) => {
|
|
224
232
|
const connected = await (0, database_1.isDbConnected)();
|
|
225
233
|
if (!connected) {
|
|
226
|
-
console.error(chalk_1.default.red(
|
|
234
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
227
235
|
await (0, database_1.closeConnection)();
|
|
228
236
|
process.exit(1);
|
|
229
237
|
}
|
|
@@ -240,7 +248,7 @@ function registerCommitmentsCommands(program) {
|
|
|
240
248
|
conditions.push(`status = $${paramIdx++}`);
|
|
241
249
|
params.push(options.status);
|
|
242
250
|
}
|
|
243
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(
|
|
251
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
244
252
|
params.push(parseInt(options.limit));
|
|
245
253
|
const result = await pool.query(`SELECT id, bot_id, status, title, description, source_type, source_ref,
|
|
246
254
|
deadline_at::text, steps,
|
|
@@ -249,27 +257,31 @@ function registerCommitmentsCommands(program) {
|
|
|
249
257
|
${where}
|
|
250
258
|
ORDER BY created_at DESC
|
|
251
259
|
LIMIT $${paramIdx}`, params);
|
|
252
|
-
if (options.format ===
|
|
260
|
+
if (options.format === 'json') {
|
|
253
261
|
console.log(JSON.stringify(result.rows, null, 2));
|
|
254
262
|
}
|
|
255
263
|
else {
|
|
256
|
-
console.log(chalk_1.default.cyan.bold(
|
|
264
|
+
console.log(chalk_1.default.cyan.bold('\n📋 Commitments\n'));
|
|
257
265
|
if (result.rows.length === 0) {
|
|
258
|
-
console.log(chalk_1.default.yellow(
|
|
266
|
+
console.log(chalk_1.default.yellow(' 약속 없음'));
|
|
259
267
|
}
|
|
260
268
|
else {
|
|
261
269
|
for (const c of result.rows) {
|
|
262
|
-
const statusColor = c.status ===
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
270
|
+
const statusColor = c.status === 'done'
|
|
271
|
+
? chalk_1.default.green
|
|
272
|
+
: c.status === 'failed'
|
|
273
|
+
? chalk_1.default.red
|
|
274
|
+
: c.status === 'expired'
|
|
275
|
+
? chalk_1.default.gray
|
|
276
|
+
: c.status === 'active'
|
|
277
|
+
? chalk_1.default.cyan
|
|
278
|
+
: chalk_1.default.yellow;
|
|
267
279
|
const deadline = c.deadline_at
|
|
268
|
-
? new Date(c.deadline_at).toLocaleString(
|
|
269
|
-
:
|
|
280
|
+
? new Date(c.deadline_at).toLocaleString('ko-KR')
|
|
281
|
+
: '-';
|
|
270
282
|
const stepsInfo = Array.isArray(c.steps) && c.steps.length > 0
|
|
271
283
|
? ` [${c.steps.filter((s) => s.done).length}/${c.steps.length}]`
|
|
272
|
-
:
|
|
284
|
+
: '';
|
|
273
285
|
console.log(` ${statusColor(c.status.padEnd(8))} ` +
|
|
274
286
|
chalk_1.default.white(c.title.slice(0, 40).padEnd(42)) +
|
|
275
287
|
chalk_1.default.gray(`${c.bot_id.padEnd(14)}`) +
|
|
@@ -289,17 +301,149 @@ function registerCommitmentsCommands(program) {
|
|
|
289
301
|
await (0, database_1.closeConnection)();
|
|
290
302
|
}
|
|
291
303
|
});
|
|
304
|
+
// ── semo commitments claim ──────────────────────────────────────────────────
|
|
305
|
+
cmd
|
|
306
|
+
.command('claim <id>')
|
|
307
|
+
.description('세션이 commitment를 점유 (assigned_session 설정)')
|
|
308
|
+
.requiredOption('--session <key>', '세션 키')
|
|
309
|
+
.option('--owner <name>', '세션 소유자 (예: reus-local)')
|
|
310
|
+
.action(async (id, options) => {
|
|
311
|
+
const connected = await (0, database_1.isDbConnected)();
|
|
312
|
+
if (!connected) {
|
|
313
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
314
|
+
await (0, database_1.closeConnection)();
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const pool = (0, database_1.getPool)();
|
|
319
|
+
// 이미 다른 세션이 점유 중이면 실패
|
|
320
|
+
const result = await pool.query(`UPDATE semo.bot_commitments
|
|
321
|
+
SET assigned_session = $2,
|
|
322
|
+
session_owner = $3,
|
|
323
|
+
status = CASE WHEN status = 'pending' THEN 'active' ELSE status END,
|
|
324
|
+
last_heartbeat_at = NOW(),
|
|
325
|
+
updated_at = NOW()
|
|
326
|
+
WHERE id = $1
|
|
327
|
+
AND status IN ('pending', 'active')
|
|
328
|
+
AND (assigned_session IS NULL OR assigned_session = $2)
|
|
329
|
+
RETURNING id, title, status`, [id, options.session, options.owner ?? null]);
|
|
330
|
+
if (result.rowCount === 0) {
|
|
331
|
+
console.error(chalk_1.default.yellow('⚠ commitment 없거나 이미 다른 세션이 점유 중'));
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
const row = result.rows[0];
|
|
336
|
+
console.log(chalk_1.default.green(`✔ claimed: ${row.id} — ${row.title} (${row.status})`));
|
|
337
|
+
console.log(JSON.stringify({ id: row.id, status: row.status, session: options.session }));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
console.error(chalk_1.default.red(`❌ claim 실패: ${err}`));
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
await (0, database_1.closeConnection)();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
// ── semo commitments release ──────────────────────────────────────────────
|
|
349
|
+
cmd
|
|
350
|
+
.command('release')
|
|
351
|
+
.description('세션 종료 시 미완료 commitment 해제 (assigned_session 초기화)')
|
|
352
|
+
.requiredOption('--session <key>', '세션 키')
|
|
353
|
+
.action(async (options) => {
|
|
354
|
+
const connected = await (0, database_1.isDbConnected)();
|
|
355
|
+
if (!connected) {
|
|
356
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
357
|
+
await (0, database_1.closeConnection)();
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const pool = (0, database_1.getPool)();
|
|
362
|
+
const result = await pool.query(`UPDATE semo.bot_commitments
|
|
363
|
+
SET assigned_session = NULL,
|
|
364
|
+
session_owner = NULL,
|
|
365
|
+
status = CASE WHEN status = 'active' THEN 'pending' ELSE status END,
|
|
366
|
+
updated_at = NOW()
|
|
367
|
+
WHERE assigned_session = $1
|
|
368
|
+
AND status IN ('pending', 'active')
|
|
369
|
+
RETURNING id, title`, [options.session]);
|
|
370
|
+
if (result.rowCount === 0) {
|
|
371
|
+
console.log(chalk_1.default.gray('ℹ 해제할 commitment 없음'));
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
for (const row of result.rows) {
|
|
375
|
+
console.log(chalk_1.default.green(`✔ released: ${row.id} — ${row.title}`));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
console.error(chalk_1.default.red(`❌ release 실패: ${err}`));
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await (0, database_1.closeConnection)();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
// ── semo commitments stale ────────────────────────────────────────────────
|
|
388
|
+
cmd
|
|
389
|
+
.command('stale')
|
|
390
|
+
.description('stale commitment 감지 (장기간 상태 변경 없음)')
|
|
391
|
+
.option('--threshold <hours>', 'stale 판단 기준 시간', '24')
|
|
392
|
+
.option('--format <type>', '출력 형식 (table|json)', 'table')
|
|
393
|
+
.action(async (options) => {
|
|
394
|
+
const connected = await (0, database_1.isDbConnected)();
|
|
395
|
+
if (!connected) {
|
|
396
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
397
|
+
await (0, database_1.closeConnection)();
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const pool = (0, database_1.getPool)();
|
|
402
|
+
const hours = parseInt(options.threshold);
|
|
403
|
+
const result = await pool.query(`SELECT id, bot_id, status, title, assigned_session, session_owner,
|
|
404
|
+
updated_at::text, EXTRACT(EPOCH FROM NOW() - updated_at)/3600 AS hours_stale
|
|
405
|
+
FROM semo.bot_commitments
|
|
406
|
+
WHERE status IN ('pending', 'active')
|
|
407
|
+
AND updated_at < NOW() - INTERVAL '1 hour' * $1
|
|
408
|
+
ORDER BY updated_at ASC`, [hours]);
|
|
409
|
+
if (options.format === 'json') {
|
|
410
|
+
console.log(JSON.stringify(result.rows, null, 2));
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
if (result.rows.length === 0) {
|
|
414
|
+
console.log(chalk_1.default.green(`✅ stale commitment 없음 (기준: ${hours}시간)`));
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
console.log(chalk_1.default.yellow.bold(`\n⏰ Stale Commitments (${hours}h+)\n`));
|
|
418
|
+
for (const c of result.rows) {
|
|
419
|
+
console.log(` ${chalk_1.default.red(Math.round(c.hours_stale) + 'h')} ` +
|
|
420
|
+
chalk_1.default.white(c.title.slice(0, 40).padEnd(42)) +
|
|
421
|
+
chalk_1.default.gray(c.bot_id.padEnd(14)) +
|
|
422
|
+
chalk_1.default.gray(c.session_owner ?? 'no-owner'));
|
|
423
|
+
}
|
|
424
|
+
console.log();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
console.error(chalk_1.default.red(`❌ stale 실패: ${err}`));
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
await (0, database_1.closeConnection)();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
292
436
|
// ── semo commitments watch ─────────────────────────────────────────────────
|
|
293
437
|
cmd
|
|
294
|
-
.command(
|
|
295
|
-
.description(
|
|
296
|
-
.option(
|
|
297
|
-
.option(
|
|
298
|
-
.option(
|
|
438
|
+
.command('watch')
|
|
439
|
+
.description('워치독 — 활성 약속의 health 상태 조회')
|
|
440
|
+
.option('--format <type>', '출력 형식 (table|json)', 'table')
|
|
441
|
+
.option('--bot-id <id>', '특정 봇만 필터')
|
|
442
|
+
.option('--exclude-bot <id>', '특정 봇 제외')
|
|
299
443
|
.action(async (options) => {
|
|
300
444
|
const connected = await (0, database_1.isDbConnected)();
|
|
301
445
|
if (!connected) {
|
|
302
|
-
console.error(chalk_1.default.red(
|
|
446
|
+
console.error(chalk_1.default.red('❌ DB 연결 실패'));
|
|
303
447
|
await (0, database_1.closeConnection)();
|
|
304
448
|
process.exit(1);
|
|
305
449
|
}
|
|
@@ -316,9 +460,7 @@ function registerCommitmentsCommands(program) {
|
|
|
316
460
|
conditions.push(`bot_id != $${paramIdx++}`);
|
|
317
461
|
params.push(options.excludeBot);
|
|
318
462
|
}
|
|
319
|
-
const whereClause = conditions.length > 0
|
|
320
|
-
? `WHERE ${conditions.join(" AND ")}`
|
|
321
|
-
: "";
|
|
463
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
322
464
|
const result = await pool.query(`SELECT id, bot_id, status, title, description, source_type, source_ref,
|
|
323
465
|
deadline_at::text,
|
|
324
466
|
health, minutes_since_heartbeat, minutes_overdue,
|
|
@@ -332,37 +474,39 @@ function registerCommitmentsCommands(program) {
|
|
|
332
474
|
ELSE 2
|
|
333
475
|
END,
|
|
334
476
|
deadline_at ASC NULLS LAST`, params);
|
|
335
|
-
if (options.format ===
|
|
477
|
+
if (options.format === 'json') {
|
|
336
478
|
const summary = {
|
|
337
479
|
total: result.rows.length,
|
|
338
|
-
overdue: result.rows.filter((r) => r.health ===
|
|
339
|
-
stale: result.rows.filter((r) => r.health ===
|
|
340
|
-
on_track: result.rows.filter((r) => r.health ===
|
|
480
|
+
overdue: result.rows.filter((r) => r.health === 'overdue').length,
|
|
481
|
+
stale: result.rows.filter((r) => r.health === 'stale').length,
|
|
482
|
+
on_track: result.rows.filter((r) => r.health === 'on-track').length,
|
|
341
483
|
commitments: result.rows,
|
|
342
484
|
};
|
|
343
485
|
console.log(JSON.stringify(summary, null, 2));
|
|
344
486
|
}
|
|
345
487
|
else {
|
|
346
|
-
console.log(chalk_1.default.cyan.bold(
|
|
488
|
+
console.log(chalk_1.default.cyan.bold('\n🔍 Commitment Watchdog\n'));
|
|
347
489
|
if (result.rows.length === 0) {
|
|
348
|
-
console.log(chalk_1.default.green(
|
|
490
|
+
console.log(chalk_1.default.green(' ✅ 활성 약속 없음 — all clear'));
|
|
349
491
|
}
|
|
350
492
|
else {
|
|
351
493
|
for (const c of result.rows) {
|
|
352
|
-
const healthColor = c.health ===
|
|
353
|
-
|
|
354
|
-
|
|
494
|
+
const healthColor = c.health === 'overdue'
|
|
495
|
+
? chalk_1.default.red.bold
|
|
496
|
+
: c.health === 'stale'
|
|
497
|
+
? chalk_1.default.yellow
|
|
498
|
+
: chalk_1.default.green;
|
|
355
499
|
const overdue = c.minutes_overdue != null && c.minutes_overdue > 0
|
|
356
500
|
? ` (${Math.round(c.minutes_overdue)}분 초과)`
|
|
357
|
-
:
|
|
501
|
+
: '';
|
|
358
502
|
console.log(` ${healthColor(c.health.padEnd(10))} ` +
|
|
359
503
|
chalk_1.default.white(c.title.slice(0, 35).padEnd(37)) +
|
|
360
504
|
chalk_1.default.gray(c.bot_id.padEnd(14)) +
|
|
361
505
|
chalk_1.default.gray(`hb: ${Math.round(c.minutes_since_heartbeat)}분 전`) +
|
|
362
506
|
chalk_1.default.red(overdue));
|
|
363
507
|
}
|
|
364
|
-
const overdue = result.rows.filter((r) => r.health ===
|
|
365
|
-
const stale = result.rows.filter((r) => r.health ===
|
|
508
|
+
const overdue = result.rows.filter((r) => r.health === 'overdue').length;
|
|
509
|
+
const stale = result.rows.filter((r) => r.health === 'stale').length;
|
|
366
510
|
console.log();
|
|
367
511
|
if (overdue > 0)
|
|
368
512
|
console.log(chalk_1.default.red.bold(` ⚠ ${overdue}건 overdue`));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semo context-preserve — PreCompact 훅에서 호출
|
|
3
|
+
*
|
|
4
|
+
* 컨텍스트 압축 전에 세션의 미기록 의사결정/로그를 KB에 flush.
|
|
5
|
+
* stdin으로 { session_id, transcript_path } 수신.
|
|
6
|
+
*/
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
export declare function registerContextPreserveCommands(program: Command): void;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* semo context-preserve — PreCompact 훅에서 호출
|
|
4
|
+
*
|
|
5
|
+
* 컨텍스트 압축 전에 세션의 미기록 의사결정/로그를 KB에 flush.
|
|
6
|
+
* stdin으로 { session_id, transcript_path } 수신.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.registerContextPreserveCommands = registerContextPreserveCommands;
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const database_1 = require("../database");
|
|
45
|
+
const kb_1 = require("../kb");
|
|
46
|
+
// 의사결정 패턴 감지
|
|
47
|
+
const DECISION_PATTERNS = [
|
|
48
|
+
/결정[:\s].*(?:하기로|했다|합의|확정)/,
|
|
49
|
+
/합의[:\s]/,
|
|
50
|
+
/방침[:\s]/,
|
|
51
|
+
/도입하기로/,
|
|
52
|
+
/폐기하기로/,
|
|
53
|
+
/변경하기로/,
|
|
54
|
+
];
|
|
55
|
+
function registerContextPreserveCommands(program) {
|
|
56
|
+
program
|
|
57
|
+
.command('context-preserve')
|
|
58
|
+
.description('PreCompact 훅 — 압축 전 KB에 결정/로그 flush')
|
|
59
|
+
.action(async () => {
|
|
60
|
+
let transcriptPath = '';
|
|
61
|
+
try {
|
|
62
|
+
const input = await readStdin();
|
|
63
|
+
if (input) {
|
|
64
|
+
const parsed = JSON.parse(input);
|
|
65
|
+
transcriptPath = parsed.transcript_path || '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// stdin 없음
|
|
70
|
+
}
|
|
71
|
+
const connected = await (0, database_1.isDbConnected)();
|
|
72
|
+
if (!connected) {
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
77
|
+
process.stderr.write('[context-preserve] transcript_path 없음, skip\n');
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
const transcript = fs.readFileSync(transcriptPath, 'utf8');
|
|
81
|
+
// 의사결정 패턴 감지
|
|
82
|
+
const decisions = [];
|
|
83
|
+
const lines = transcript.split('\n');
|
|
84
|
+
for (let i = 0; i < lines.length; i++) {
|
|
85
|
+
const line = lines[i];
|
|
86
|
+
if (DECISION_PATTERNS.some((p) => p.test(line))) {
|
|
87
|
+
// 전후 2줄 컨텍스트 포함
|
|
88
|
+
const context = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3));
|
|
89
|
+
decisions.push(context.join('\n'));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (decisions.length === 0) {
|
|
93
|
+
process.stderr.write('[context-preserve] 기록할 의사결정 없음\n');
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
// KB에 세션 로그로 기록
|
|
97
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
98
|
+
const content = `# 세션 의사결정 로그 (${today})\n\n${decisions.map((d, i) => `## ${i + 1}\n${d}`).join('\n\n')}`;
|
|
99
|
+
const pool = (0, database_1.getPool)();
|
|
100
|
+
await (0, kb_1.kbPush)(pool, [
|
|
101
|
+
{
|
|
102
|
+
domain: 'semicolon',
|
|
103
|
+
key: 'session-log',
|
|
104
|
+
sub_key: `${today}-${Date.now()}`,
|
|
105
|
+
content,
|
|
106
|
+
metadata: { source: 'context-preserve', decision_count: decisions.length },
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
process.stderr.write(`[context-preserve] ${decisions.length}건 의사결정 KB에 기록\n`);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
process.stderr.write(`[context-preserve] error: ${err}\n`);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await (0, database_1.closeConnection)();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function readStdin() {
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
let data = '';
|
|
122
|
+
process.stdin.setEncoding('utf8');
|
|
123
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
124
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
125
|
+
setTimeout(() => resolve(data.trim()), 500);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -103,26 +103,18 @@ function writeClaudeMd(sessionDir, serviceId, serviceName, channel) {
|
|
|
103
103
|
유저 메시지를 받으면 아래 우선순위로 봇 Agent를 선택:
|
|
104
104
|
|
|
105
105
|
1. **명시적 봇 지정**: "디자인팀에게 물어봐" → Agent(designclaw)
|
|
106
|
-
2. **현재 Phase 기반**: Dashboard API에서 current_phase 조회 →
|
|
106
|
+
2. **현재 Phase 기반**: Dashboard API에서 current_phase 조회 → Phase별 담당 봇 매핑
|
|
107
107
|
3. **키워드 분기**: 인프라/배포 → infraclaw, 리뷰/PR → reviewclaw, 마케팅/SEO → growthclaw
|
|
108
108
|
4. **폴백**: Agent(semiclaw) — PM/오케스트레이터
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
| Phase | 봇 | 트랙 |
|
|
113
|
-
|-------|-----|------|
|
|
114
|
-
| 0 | semiclaw | A |
|
|
115
|
-
| 1-3 | planclaw | A |
|
|
116
|
-
| 4 | designclaw | A |
|
|
117
|
-
| 5-6 | planclaw | A |
|
|
118
|
-
| 7-8 | workclaw | A |
|
|
119
|
-
| 9 | planclaw | A |
|
|
120
|
-
| Infra 0-2 | infraclaw | B |
|
|
110
|
+
Phase → 봇 매핑은 Dashboard API \`GET /api/projects/{service_id}\`의 current_phase에 따름.
|
|
111
|
+
봇 프로필은 KB에서 동적 로드됨 (\`semo kb get {botId} slack-config\`).
|
|
121
112
|
|
|
122
113
|
### 응답 포맷
|
|
123
114
|
|
|
124
|
-
|
|
125
|
-
예: \`
|
|
115
|
+
reply() 호출 시 반드시 \`bot_id\` 파라미터를 전달하여 봇 정체성을 표시.
|
|
116
|
+
예: \`reply(text="분석 결과입니다...", bot_id="planclaw", ...)\`
|
|
117
|
+
응답 텍스트에 [봇이름] 접두사를 붙이지 않는다 — bot_id가 Slack 표시를 자동 처리.
|
|
126
118
|
|
|
127
119
|
## KB 도메인
|
|
128
120
|
|
package/dist/global-cache.js
CHANGED
|
@@ -161,9 +161,8 @@ async function syncGlobalCache(claudeDir) {
|
|
|
161
161
|
cmdCount++;
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
|
-
// 3. 에이전트 설치 (
|
|
164
|
+
// 3. 에이전트 설치 (머지 모드 — 로컬 YAML frontmatter 보존)
|
|
165
165
|
const agentsDir = path.join(dir, 'agents');
|
|
166
|
-
removeRecursive(agentsDir);
|
|
167
166
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
168
167
|
const seenAgentNames = new Set();
|
|
169
168
|
const dedupedAgents = [];
|
|
@@ -174,30 +173,62 @@ async function syncGlobalCache(claudeDir) {
|
|
|
174
173
|
dedupedAgents.push(agent);
|
|
175
174
|
}
|
|
176
175
|
}
|
|
176
|
+
// 기존 로컬 파일의 frontmatter 캐싱 (덮어쓰기 전)
|
|
177
|
+
const existingFrontmatters = new Map();
|
|
178
|
+
for (const folder of fs.readdirSync(agentsDir).filter((f) => !f.startsWith('.'))) {
|
|
179
|
+
const filePath = path.join(agentsDir, folder, `${folder}.md`);
|
|
180
|
+
if (fs.existsSync(filePath)) {
|
|
181
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
182
|
+
const fmMatch = existing.match(/^---\n([\s\S]*?)\n---\n/);
|
|
183
|
+
if (fmMatch) {
|
|
184
|
+
existingFrontmatters.set(folder.toLowerCase(), fmMatch[1]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// DB에 없는 에이전트 폴더 정리 (orphan 제거)
|
|
189
|
+
const dbAgentNames = new Set(dedupedAgents.map((a) => a.name.toLowerCase()));
|
|
190
|
+
for (const folder of fs.readdirSync(agentsDir).filter((f) => !f.startsWith('.'))) {
|
|
191
|
+
const folderPath = path.join(agentsDir, folder);
|
|
192
|
+
if (!dbAgentNames.has(folder.toLowerCase()) && fs.statSync(folderPath).isDirectory()) {
|
|
193
|
+
removeRecursive(folderPath);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
177
196
|
for (const agent of dedupedAgents) {
|
|
178
197
|
const agentFolder = path.join(agentsDir, agent.name);
|
|
179
198
|
fs.mkdirSync(agentFolder, { recursive: true });
|
|
180
|
-
|
|
199
|
+
// DB content에서 frontmatter 제거 (body만 추출)
|
|
200
|
+
let dbBody = agent.content;
|
|
201
|
+
const dbFmMatch = agent.content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
|
|
202
|
+
if (dbFmMatch) {
|
|
203
|
+
dbBody = dbFmMatch[1].trim();
|
|
204
|
+
}
|
|
181
205
|
// 위임 매트릭스 주입
|
|
182
206
|
const agentDelegations = delegations.filter((d) => d.from_bot_id === agent.name);
|
|
183
207
|
if (agentDelegations.length > 0) {
|
|
184
208
|
const delegationLines = agentDelegations
|
|
185
209
|
.map((d) => `- → ${d.to_bot_id}: ${d.domains.join(', ')} (via ${d.method})`)
|
|
186
210
|
.join('\n');
|
|
187
|
-
|
|
211
|
+
dbBody += `\n\n## 위임 매트릭스\n${delegationLines}\n`;
|
|
188
212
|
}
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
213
|
+
// 로컬 frontmatter 보존 (있으면) → DB body와 합침
|
|
214
|
+
const localFm = existingFrontmatters.get(agent.name.toLowerCase());
|
|
215
|
+
let content;
|
|
216
|
+
if (localFm) {
|
|
217
|
+
// 로컬 frontmatter 우선 보존 + DB body 업데이트
|
|
218
|
+
content = `---\n${localFm}\n---\n${dbBody}`;
|
|
219
|
+
}
|
|
220
|
+
else if (agent.metadata && (agent.metadata.model || agent.metadata.description)) {
|
|
221
|
+
// 로컬 frontmatter 없으면 metadata에서 생성
|
|
222
|
+
const fm = ['---'];
|
|
223
|
+
if (agent.metadata.description)
|
|
224
|
+
fm.push(`description: "${agent.metadata.description}"`);
|
|
225
|
+
if (agent.metadata.model)
|
|
226
|
+
fm.push(`model: "${agent.metadata.model}"`);
|
|
227
|
+
fm.push('---', '');
|
|
228
|
+
content = fm.join('\n') + dbBody;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
content = dbBody;
|
|
201
232
|
}
|
|
202
233
|
fs.writeFileSync(path.join(agentFolder, `${agent.name}.md`), content);
|
|
203
234
|
}
|
package/dist/index.js
CHANGED
|
@@ -66,6 +66,8 @@ const db_1 = require("./commands/db");
|
|
|
66
66
|
const memory_1 = require("./commands/memory");
|
|
67
67
|
const test_1 = require("./commands/test");
|
|
68
68
|
const commitments_1 = require("./commands/commitments");
|
|
69
|
+
const agent_flush_1 = require("./commands/agent-flush");
|
|
70
|
+
const context_preserve_1 = require("./commands/context-preserve");
|
|
69
71
|
const service_1 = require("./commands/service");
|
|
70
72
|
const harness_1 = require("./commands/harness");
|
|
71
73
|
const incubator_1 = require("./commands/incubator");
|
|
@@ -890,6 +892,18 @@ async function setupHooks(isUpdate = false) {
|
|
|
890
892
|
],
|
|
891
893
|
},
|
|
892
894
|
],
|
|
895
|
+
PreCompact: [
|
|
896
|
+
{
|
|
897
|
+
matcher: 'auto',
|
|
898
|
+
hooks: [
|
|
899
|
+
{
|
|
900
|
+
type: 'command',
|
|
901
|
+
command: '. ~/.claude/semo/.env 2>/dev/null; semo context-preserve 2>/dev/null || true',
|
|
902
|
+
timeout: 10000,
|
|
903
|
+
},
|
|
904
|
+
],
|
|
905
|
+
},
|
|
906
|
+
],
|
|
893
907
|
};
|
|
894
908
|
// 기존 설정 로드 또는 새로 생성
|
|
895
909
|
let existingSettings = {};
|
|
@@ -1690,6 +1704,7 @@ kbCmd
|
|
|
1690
1704
|
.requiredOption('--content <text>', '항목 본문')
|
|
1691
1705
|
.option('--metadata <json>', '추가 메타데이터 (JSON 문자열)')
|
|
1692
1706
|
.option('--created-by <name>', '작성자 식별자', 'semo-cli')
|
|
1707
|
+
.option('--expect-version <n>', 'Optimistic locking — 기대 version (불일치 시 실패)')
|
|
1693
1708
|
.action(async (domain, key, subKey, options) => {
|
|
1694
1709
|
const spinner = (0, ora_1.default)('KB upsert 중...').start();
|
|
1695
1710
|
try {
|
|
@@ -1702,6 +1717,7 @@ kbCmd
|
|
|
1702
1717
|
content: options.content,
|
|
1703
1718
|
metadata,
|
|
1704
1719
|
created_by: options.createdBy,
|
|
1720
|
+
expect_version: options.expectVersion ? parseInt(options.expectVersion) : undefined,
|
|
1705
1721
|
});
|
|
1706
1722
|
if (result.success) {
|
|
1707
1723
|
spinner.succeed(`KB upsert 완료: ${domain}/${key}${subKey ? '/' + subKey : ''}`);
|
|
@@ -2410,6 +2426,8 @@ ontoCmd
|
|
|
2410
2426
|
(0, service_1.registerServiceCommands)(program);
|
|
2411
2427
|
(0, harness_1.registerHarnessCommands)(program);
|
|
2412
2428
|
(0, incubator_1.registerIncubatorCommands)(program);
|
|
2429
|
+
(0, agent_flush_1.registerAgentFlushCommands)(program);
|
|
2430
|
+
(0, context_preserve_1.registerContextPreserveCommands)(program);
|
|
2413
2431
|
// === semo skills — DB 시딩 ===
|
|
2414
2432
|
/**
|
|
2415
2433
|
* SKILL.md frontmatter 파싱 (YAML 파서 없이 regex 기반)
|
package/dist/kb.d.ts
CHANGED
package/dist/kb.js
CHANGED
|
@@ -886,20 +886,44 @@ async function kbUpsert(pool, entry) {
|
|
|
886
886
|
const embeddingStr = `[${embedding.join(',')}]`;
|
|
887
887
|
const writeClient = await pool.connect();
|
|
888
888
|
try {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
metadata =
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
889
|
+
if (entry.expect_version !== undefined) {
|
|
890
|
+
// Optimistic locking: version 불일치 시 실패
|
|
891
|
+
// version/updated_at는 트리거(trg_kb_updated_at)가 자동 처리하므로 명시하지 않음
|
|
892
|
+
const result = await writeClient.query(`UPDATE semo.knowledge_base
|
|
893
|
+
SET content = $1, metadata = $2, embedding = $3::vector
|
|
894
|
+
WHERE domain = $4 AND key = $5 AND sub_key = $6 AND version = $7
|
|
895
|
+
RETURNING version`, [
|
|
896
|
+
entry.content,
|
|
897
|
+
JSON.stringify(entry.metadata || {}),
|
|
898
|
+
embeddingStr,
|
|
899
|
+
entry.domain,
|
|
900
|
+
key,
|
|
901
|
+
subKey,
|
|
902
|
+
entry.expect_version,
|
|
903
|
+
]);
|
|
904
|
+
if (result.rowCount === 0) {
|
|
905
|
+
return {
|
|
906
|
+
success: false,
|
|
907
|
+
error: `version 충돌: 다른 세션이 이미 수정했습니다 (expected version ${entry.expect_version}). 다시 조회 후 재시도하세요.`,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
await writeClient.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
|
|
913
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
|
|
914
|
+
ON CONFLICT (domain, key, sub_key) DO UPDATE SET
|
|
915
|
+
content = EXCLUDED.content,
|
|
916
|
+
metadata = EXCLUDED.metadata,
|
|
917
|
+
embedding = EXCLUDED.embedding`, [
|
|
918
|
+
entry.domain,
|
|
919
|
+
key,
|
|
920
|
+
subKey,
|
|
921
|
+
entry.content,
|
|
922
|
+
JSON.stringify(entry.metadata || {}),
|
|
923
|
+
entry.created_by || 'semo-cli',
|
|
924
|
+
embeddingStr,
|
|
925
|
+
]);
|
|
926
|
+
}
|
|
903
927
|
// KB→DB 동기화: Dashboard API에 위임 (파서 단일화)
|
|
904
928
|
if ((key === 'kpi' || key === 'action-item') && subKey) {
|
|
905
929
|
try {
|