@team-semicolon/semo-cli 4.15.1 → 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.
@@ -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
+ }
@@ -4,5 +4,5 @@
4
4
  * 봇이 "~하겠습니다" 약속 후 세션 종료되어도 추적 가능하도록
5
5
  * semo.bot_commitments 테이블에 기록하고, 워치독이 overdue/stale 감지.
6
6
  */
7
- import { Command } from "commander";
7
+ import { Command } from 'commander';
8
8
  export declare function registerCommitmentsCommands(program: Command): void;
@@ -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 === "m")
27
+ if (unit === 'm')
28
28
  now.setMinutes(now.getMinutes() + value);
29
- else if (unit === "h")
29
+ else if (unit === 'h')
30
30
  now.setHours(now.getHours() + value);
31
- else if (unit === "d")
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("commitments")
42
- .description("봇 약속 추적 (Durable Commitment Tracking)");
41
+ .command('commitments')
42
+ .description('봇 약속 추적 (Durable Commitment Tracking)');
43
43
  // ── semo commitments create ────────────────────────────────────────────────
44
44
  cmd
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}]\')")
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("❌ DB 연결 실패"));
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("❌ --steps JSON 파싱 실패"));
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({ id, bot_id: options.botId, title: options.title, deadline_at: deadlineAt }));
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("update <id>")
105
- .description("진행 보고 (heartbeat, status, step 완료)")
106
- .option("--heartbeat", "heartbeat 갱신")
107
- .option("--status <status>", "상태 변경 (active|pending)")
108
- .option("--step-done <label>", "특정 step을 완료 처리")
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("❌ DB 연결 실패"));
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`, [options.status, id]);
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("done <id>")
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("❌ DB 연결 실패"));
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("fail <id>")
184
- .description("약속 실패 처리")
185
- .option("--reason <text>", "실패 사유")
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("❌ DB 연결 실패"));
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("list")
218
- .description("약속 목록 조회")
219
- .option("--bot-id <id>", "특정 봇만")
220
- .option("--status <status>", "상태 필터 (pending|active|done|failed|expired)")
221
- .option("--limit <n>", "최대 조회 수", "20")
222
- .option("--format <type>", "출력 형식 (table|json)", "table")
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("❌ DB 연결 실패"));
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(" AND ")}` : "";
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 === "json") {
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("\n📋 Commitments\n"));
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 === "done" ? chalk_1.default.green :
263
- c.status === "failed" ? chalk_1.default.red :
264
- c.status === "expired" ? chalk_1.default.gray :
265
- c.status === "active" ? chalk_1.default.cyan :
266
- chalk_1.default.yellow;
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("ko-KR")
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("watch")
295
- .description("워치독 — 활성 약속의 health 상태 조회")
296
- .option("--format <type>", "출력 형식 (table|json)", "table")
297
- .option("--bot-id <id>", "특정 봇만 필터")
298
- .option("--exclude-bot <id>", "특정 봇 제외")
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("❌ DB 연결 실패"));
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 === "json") {
477
+ if (options.format === 'json') {
336
478
  const summary = {
337
479
  total: result.rows.length,
338
- overdue: result.rows.filter((r) => r.health === "overdue").length,
339
- stale: result.rows.filter((r) => r.health === "stale").length,
340
- on_track: result.rows.filter((r) => r.health === "on-track").length,
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("\n🔍 Commitment Watchdog\n"));
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(" ✅ 활성 약속 없음 — all clear"));
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 === "overdue" ? chalk_1.default.red.bold :
353
- c.health === "stale" ? chalk_1.default.yellow :
354
- chalk_1.default.green;
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 === "overdue").length;
365
- const stale = result.rows.filter((r) => r.health === "stale").length;
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 조회 → PHASE_ASSIGNEES 매핑
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
- ### Phase → 봇 매핑
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
- 예: \`[PlanClaw] 비즈니스 모델 분석 결과입니다...\`
115
+ reply() 호출 반드시 \`bot_id\` 파라미터를 전달하여 정체성을 표시.
116
+ 예: \`reply(text="분석 결과입니다...", bot_id="planclaw", ...)\`
117
+ 응답 텍스트에 [봇이름] 접두사를 붙이지 않는다 — bot_id가 Slack 표시를 자동 처리.
126
118
 
127
119
  ## KB 도메인
128
120
 
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
@@ -154,6 +154,7 @@ export declare function kbUpsert(pool: Pool, entry: {
154
154
  content: string;
155
155
  metadata?: Record<string, unknown>;
156
156
  created_by?: string;
157
+ expect_version?: number;
157
158
  }): Promise<{
158
159
  success: boolean;
159
160
  error?: string;
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
- await writeClient.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
890
- VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
891
- ON CONFLICT (domain, key, sub_key) DO UPDATE SET
892
- content = EXCLUDED.content,
893
- metadata = EXCLUDED.metadata,
894
- embedding = EXCLUDED.embedding`, [
895
- entry.domain,
896
- key,
897
- subKey,
898
- entry.content,
899
- JSON.stringify(entry.metadata || {}),
900
- entry.created_by || 'semo-cli',
901
- embeddingStr,
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semo-cli",
3
- "version": "4.15.1",
3
+ "version": "4.16.0",
4
4
  "description": "SEMO CLI - AI Agent Orchestration Framework Installer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {