@team-semicolon/semo-cli 4.1.4 → 4.2.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/README.md +3 -4
- package/dist/commands/audit.d.ts +27 -0
- package/dist/commands/audit.js +338 -0
- package/dist/commands/bots.js +524 -24
- package/dist/commands/context.d.ts +14 -3
- package/dist/commands/context.js +192 -113
- package/dist/commands/db.d.ts +9 -0
- package/dist/commands/db.js +189 -0
- package/dist/commands/get.d.ts +1 -2
- package/dist/commands/get.js +24 -116
- package/dist/commands/sessions.d.ts +2 -1
- package/dist/commands/sessions.js +31 -62
- package/dist/commands/skill-sync.d.ts +28 -0
- package/dist/commands/skill-sync.js +111 -0
- package/dist/commands/skill-sync.test.d.ts +16 -0
- package/dist/commands/skill-sync.test.js +186 -0
- package/dist/database.d.ts +41 -3
- package/dist/database.js +128 -554
- package/dist/env-parser.d.ts +5 -0
- package/dist/env-parser.js +27 -0
- package/dist/global-cache.d.ts +12 -0
- package/dist/global-cache.js +184 -0
- package/dist/index.js +352 -817
- package/dist/kb.d.ts +24 -39
- package/dist/kb.js +121 -175
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,7 +112,6 @@ semo update --skip-cli # CLI 업데이트 건너뛰기
|
|
|
112
112
|
| 패키지 | 설명 |
|
|
113
113
|
|--------|------|
|
|
114
114
|
| `semo-core` | 원칙, 오케스트레이터, 공통 커맨드 |
|
|
115
|
-
| `semo-skills` | 13개 통합 스킬 (coder, tester, planner, deployer 등) |
|
|
116
115
|
|
|
117
116
|
### Extensions (선택)
|
|
118
117
|
|
|
@@ -166,8 +165,8 @@ your-project/
|
|
|
166
165
|
│ └── commands/SEMO/ # SEMO 커맨드
|
|
167
166
|
│
|
|
168
167
|
└── semo-system/ # White Box (읽기 전용)
|
|
169
|
-
├── semo-core/ #
|
|
170
|
-
├──
|
|
168
|
+
├── semo-core/ # 원칙, 오케스트레이션
|
|
169
|
+
├── bot-workspaces/ # 봇 전용 스킬/컨텍스트
|
|
171
170
|
├── biz/ # Business Layer (선택)
|
|
172
171
|
├── eng/ # Engineering Layer (선택)
|
|
173
172
|
└── ops/ # Operations Layer (선택)
|
|
@@ -203,7 +202,7 @@ MCP 연동을 위해 다음 환경변수를 설정하세요:
|
|
|
203
202
|
| `next.config.js`, `next.config.mjs`, `next.config.ts` | `eng/nextjs` |
|
|
204
203
|
| `pom.xml`, `build.gradle` | `eng/spring` |
|
|
205
204
|
| `Dockerfile`, `docker-compose.yml` | `eng/infra` |
|
|
206
|
-
| `semo-core
|
|
205
|
+
| `semo-core` | `meta` |
|
|
207
206
|
|
|
208
207
|
## 레거시 명령어 호환
|
|
209
208
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot Workspace Audit — 표준 구조 compliance 감사
|
|
3
|
+
*
|
|
4
|
+
* 12가지 체크를 수행하고 점수/등급을 산정한다.
|
|
5
|
+
* - 파일 9개 (root 7 + memory/slim + skills/)
|
|
6
|
+
* - KB 3개 (team, process, decision 도메인 존재)
|
|
7
|
+
* --fix 옵션으로 누락 파일/디렉토리 자동 생성 가능.
|
|
8
|
+
*/
|
|
9
|
+
import { Pool, PoolClient } from "pg";
|
|
10
|
+
export interface AuditCheck {
|
|
11
|
+
name: string;
|
|
12
|
+
passed: boolean;
|
|
13
|
+
detail: string;
|
|
14
|
+
}
|
|
15
|
+
export interface BotAuditResult {
|
|
16
|
+
botId: string;
|
|
17
|
+
rating: "GOOD" | "NEEDS-WORK" | "POOR";
|
|
18
|
+
score: number;
|
|
19
|
+
checks: AuditCheck[];
|
|
20
|
+
}
|
|
21
|
+
export declare function auditBot(botDir: string, botId: string): BotAuditResult;
|
|
22
|
+
export declare function fixBot(botDir: string, botId: string, checks: AuditCheck[]): number;
|
|
23
|
+
export declare function auditBotKb(pool: Pool): Promise<AuditCheck[]>;
|
|
24
|
+
export declare function auditBotDb(botId: string, pool: Pool): Promise<AuditCheck[]>;
|
|
25
|
+
export declare function mergeDbChecks(result: BotAuditResult, dbChecks: AuditCheck[]): BotAuditResult;
|
|
26
|
+
export declare function formatAuditSlack(results: BotAuditResult[]): string;
|
|
27
|
+
export declare function storeAuditResults(results: BotAuditResult[], client: PoolClient): Promise<void>;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Bot Workspace Audit — 표준 구조 compliance 감사
|
|
4
|
+
*
|
|
5
|
+
* 12가지 체크를 수행하고 점수/등급을 산정한다.
|
|
6
|
+
* - 파일 9개 (root 7 + memory/slim + skills/)
|
|
7
|
+
* - KB 3개 (team, process, decision 도메인 존재)
|
|
8
|
+
* --fix 옵션으로 누락 파일/디렉토리 자동 생성 가능.
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.auditBot = auditBot;
|
|
45
|
+
exports.fixBot = fixBot;
|
|
46
|
+
exports.auditBotKb = auditBotKb;
|
|
47
|
+
exports.auditBotDb = auditBotDb;
|
|
48
|
+
exports.mergeDbChecks = mergeDbChecks;
|
|
49
|
+
exports.formatAuditSlack = formatAuditSlack;
|
|
50
|
+
exports.storeAuditResults = storeAuditResults;
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
const crypto_1 = require("crypto");
|
|
54
|
+
function fileExistsCheck(name, relativePath) {
|
|
55
|
+
return {
|
|
56
|
+
name,
|
|
57
|
+
check: (botDir) => {
|
|
58
|
+
const fullPath = path.join(botDir, relativePath);
|
|
59
|
+
const exists = fs.existsSync(fullPath);
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
passed: exists,
|
|
63
|
+
detail: exists ? `${relativePath} exists` : `${relativePath} missing`,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function dirExistsCheck(name, relativePath) {
|
|
69
|
+
return {
|
|
70
|
+
name,
|
|
71
|
+
check: (botDir) => {
|
|
72
|
+
const fullPath = path.join(botDir, relativePath);
|
|
73
|
+
const exists = fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory();
|
|
74
|
+
return {
|
|
75
|
+
name,
|
|
76
|
+
passed: exists,
|
|
77
|
+
detail: exists
|
|
78
|
+
? `${relativePath}/ exists`
|
|
79
|
+
: `${relativePath}/ missing`,
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function memorySlimCheck() {
|
|
85
|
+
return {
|
|
86
|
+
name: "memory/slim",
|
|
87
|
+
check: (botDir) => {
|
|
88
|
+
const memoryPath = path.join(botDir, "MEMORY.md");
|
|
89
|
+
if (!fs.existsSync(memoryPath)) {
|
|
90
|
+
return { name: "memory/slim", passed: true, detail: "MEMORY.md not present (N/A)" };
|
|
91
|
+
}
|
|
92
|
+
const lines = fs.readFileSync(memoryPath, "utf-8").split("\n").length;
|
|
93
|
+
const passed = lines < 50;
|
|
94
|
+
return {
|
|
95
|
+
name: "memory/slim",
|
|
96
|
+
passed,
|
|
97
|
+
detail: passed
|
|
98
|
+
? `MEMORY.md is ${lines} lines (< 50)`
|
|
99
|
+
: `MEMORY.md is ${lines} lines (>= 50, bloated)`,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const CHECK_DEFS = [
|
|
105
|
+
// 1-7: Root files
|
|
106
|
+
fileExistsCheck("root/SOUL.md", "SOUL.md"),
|
|
107
|
+
fileExistsCheck("root/IDENTITY.md", "IDENTITY.md"),
|
|
108
|
+
fileExistsCheck("root/AGENTS.md", "AGENTS.md"),
|
|
109
|
+
fileExistsCheck("root/USER.md", "USER.md"),
|
|
110
|
+
fileExistsCheck("root/TOOLS.md", "TOOLS.md"),
|
|
111
|
+
fileExistsCheck("root/RULES.md", "RULES.md"),
|
|
112
|
+
fileExistsCheck("root/MEMORY.md", "MEMORY.md"),
|
|
113
|
+
// 8: Memory slim
|
|
114
|
+
memorySlimCheck(),
|
|
115
|
+
// 9: skills/
|
|
116
|
+
dirExistsCheck("skills/", "skills"),
|
|
117
|
+
];
|
|
118
|
+
// ============================================================
|
|
119
|
+
// Core audit function
|
|
120
|
+
// ============================================================
|
|
121
|
+
function auditBot(botDir, botId) {
|
|
122
|
+
const checks = CHECK_DEFS.map((def) => def.check(botDir, botId));
|
|
123
|
+
const passed = checks.filter((c) => c.passed).length;
|
|
124
|
+
const total = checks.length;
|
|
125
|
+
const score = Math.round((passed / total) * 100);
|
|
126
|
+
const memorySlim = checks.find((c) => c.name === "memory/slim");
|
|
127
|
+
const isMemorySlim = memorySlim ? memorySlim.passed : true;
|
|
128
|
+
let rating;
|
|
129
|
+
if (score >= 80 && isMemorySlim) {
|
|
130
|
+
rating = "GOOD";
|
|
131
|
+
}
|
|
132
|
+
else if (score >= 50) {
|
|
133
|
+
rating = "NEEDS-WORK";
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
rating = "POOR";
|
|
137
|
+
}
|
|
138
|
+
return { botId, rating, score, checks };
|
|
139
|
+
}
|
|
140
|
+
// ============================================================
|
|
141
|
+
// Auto-fix
|
|
142
|
+
// ============================================================
|
|
143
|
+
const FIXABLE_FILES = {
|
|
144
|
+
"root/SOUL.md": "SOUL.md",
|
|
145
|
+
"root/IDENTITY.md": "IDENTITY.md",
|
|
146
|
+
"root/AGENTS.md": "AGENTS.md",
|
|
147
|
+
"root/USER.md": "USER.md",
|
|
148
|
+
"root/TOOLS.md": "TOOLS.md",
|
|
149
|
+
"root/RULES.md": "RULES.md",
|
|
150
|
+
"root/MEMORY.md": "MEMORY.md",
|
|
151
|
+
};
|
|
152
|
+
const FIXABLE_DIRS = {
|
|
153
|
+
"skills/": "skills",
|
|
154
|
+
};
|
|
155
|
+
const FILE_TEMPLATES = {
|
|
156
|
+
"RULES.md": (botId) => `# RULES.md — ${botId} 행동 규칙\n\n> 공식 표준: kb_get(domain='process', key='bot-workspace-standard')\n\n## NON-NEGOTIABLE\n\n## 행동 원칙\n\n## 금지 사항\n`,
|
|
157
|
+
};
|
|
158
|
+
function fixBot(botDir, botId, checks) {
|
|
159
|
+
let fixed = 0;
|
|
160
|
+
for (const check of checks) {
|
|
161
|
+
if (check.passed)
|
|
162
|
+
continue;
|
|
163
|
+
// Fix missing files
|
|
164
|
+
if (FIXABLE_FILES[check.name]) {
|
|
165
|
+
const relPath = FIXABLE_FILES[check.name];
|
|
166
|
+
const fullPath = path.join(botDir, relPath);
|
|
167
|
+
const dir = path.dirname(fullPath);
|
|
168
|
+
if (!fs.existsSync(dir)) {
|
|
169
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
170
|
+
}
|
|
171
|
+
const filename = path.basename(relPath);
|
|
172
|
+
const templateFn = FILE_TEMPLATES[relPath];
|
|
173
|
+
const content = templateFn
|
|
174
|
+
? templateFn(botId)
|
|
175
|
+
: `# ${filename}\n\n> TODO: ${botId}\n`;
|
|
176
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
177
|
+
fixed++;
|
|
178
|
+
}
|
|
179
|
+
// Fix missing directories
|
|
180
|
+
if (FIXABLE_DIRS[check.name]) {
|
|
181
|
+
const relPath = FIXABLE_DIRS[check.name];
|
|
182
|
+
const fullPath = path.join(botDir, relPath);
|
|
183
|
+
if (!fs.existsSync(fullPath)) {
|
|
184
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
185
|
+
fixed++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return fixed;
|
|
190
|
+
}
|
|
191
|
+
// ============================================================
|
|
192
|
+
// DB storage
|
|
193
|
+
// ============================================================
|
|
194
|
+
// ============================================================
|
|
195
|
+
// KB domain checks (async, requires pool)
|
|
196
|
+
// ============================================================
|
|
197
|
+
const KB_REQUIRED_DOMAINS = ["team", "process", "decision"];
|
|
198
|
+
async function auditBotKb(pool) {
|
|
199
|
+
const checks = [];
|
|
200
|
+
try {
|
|
201
|
+
const result = await pool.query(`SELECT domain, COUNT(*)::int as cnt
|
|
202
|
+
FROM semo.knowledge_base
|
|
203
|
+
WHERE domain = ANY($1)
|
|
204
|
+
GROUP BY domain`, [KB_REQUIRED_DOMAINS]);
|
|
205
|
+
const counts = new Map(result.rows.map((r) => [r.domain, r.cnt]));
|
|
206
|
+
for (const domain of KB_REQUIRED_DOMAINS) {
|
|
207
|
+
const cnt = counts.get(domain) ?? 0;
|
|
208
|
+
checks.push({
|
|
209
|
+
name: `kb/${domain}`,
|
|
210
|
+
passed: cnt > 0,
|
|
211
|
+
detail: cnt > 0
|
|
212
|
+
? `KB ${domain} 도메인: ${cnt}개 엔트리`
|
|
213
|
+
: `KB ${domain} 도메인: 엔트리 없음`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
for (const domain of KB_REQUIRED_DOMAINS) {
|
|
219
|
+
checks.push({
|
|
220
|
+
name: `kb/${domain}`,
|
|
221
|
+
passed: false,
|
|
222
|
+
detail: `KB 조회 실패: ${err}`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return checks;
|
|
227
|
+
}
|
|
228
|
+
// ============================================================
|
|
229
|
+
// DB sync checks (async, requires pool)
|
|
230
|
+
// ============================================================
|
|
231
|
+
const SYNC_STALE_HOURS = 24;
|
|
232
|
+
async function auditBotDb(botId, pool) {
|
|
233
|
+
const checks = [];
|
|
234
|
+
try {
|
|
235
|
+
const result = await pool.query("SELECT synced_at FROM semo.bot_status WHERE bot_id = $1", [botId]);
|
|
236
|
+
const registered = result.rows.length > 0;
|
|
237
|
+
checks.push({
|
|
238
|
+
name: "db/registered",
|
|
239
|
+
passed: registered,
|
|
240
|
+
detail: registered ? "bot_status에 등록됨" : "bot_status에 미등록",
|
|
241
|
+
});
|
|
242
|
+
if (registered) {
|
|
243
|
+
const syncedAt = result.rows[0].synced_at;
|
|
244
|
+
const hoursAgo = syncedAt
|
|
245
|
+
? (Date.now() - new Date(syncedAt).getTime()) / 3600000
|
|
246
|
+
: Infinity;
|
|
247
|
+
const recent = hoursAgo < SYNC_STALE_HOURS;
|
|
248
|
+
checks.push({
|
|
249
|
+
name: "db/synced_recent",
|
|
250
|
+
passed: recent,
|
|
251
|
+
detail: recent
|
|
252
|
+
? `${Math.round(hoursAgo)}시간 전 동기화`
|
|
253
|
+
: syncedAt
|
|
254
|
+
? `${Math.round(hoursAgo)}시간 전 (>${SYNC_STALE_HOURS}h stale)`
|
|
255
|
+
: "synced_at 없음",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
checks.push({
|
|
260
|
+
name: "db/synced_recent",
|
|
261
|
+
passed: false,
|
|
262
|
+
detail: "DB 미등록 — 동기화 불가",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
checks.push({
|
|
268
|
+
name: "db/registered",
|
|
269
|
+
passed: false,
|
|
270
|
+
detail: `DB 조회 실패: ${err}`,
|
|
271
|
+
});
|
|
272
|
+
checks.push({
|
|
273
|
+
name: "db/synced_recent",
|
|
274
|
+
passed: false,
|
|
275
|
+
detail: "DB 조회 실패",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return checks;
|
|
279
|
+
}
|
|
280
|
+
function mergeDbChecks(result, dbChecks) {
|
|
281
|
+
const allChecks = [...result.checks, ...dbChecks];
|
|
282
|
+
const passed = allChecks.filter((c) => c.passed).length;
|
|
283
|
+
const total = allChecks.length;
|
|
284
|
+
const score = Math.round((passed / total) * 100);
|
|
285
|
+
const memorySlim = allChecks.find((c) => c.name === "memory/slim");
|
|
286
|
+
const isMemorySlim = memorySlim ? memorySlim.passed : true;
|
|
287
|
+
let rating;
|
|
288
|
+
if (score >= 80 && isMemorySlim) {
|
|
289
|
+
rating = "GOOD";
|
|
290
|
+
}
|
|
291
|
+
else if (score >= 50) {
|
|
292
|
+
rating = "NEEDS-WORK";
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
rating = "POOR";
|
|
296
|
+
}
|
|
297
|
+
return { botId: result.botId, rating, score, checks: allChecks };
|
|
298
|
+
}
|
|
299
|
+
// ============================================================
|
|
300
|
+
// Slack formatter
|
|
301
|
+
// ============================================================
|
|
302
|
+
const RATING_EMOJI = {
|
|
303
|
+
GOOD: "🟢",
|
|
304
|
+
"NEEDS-WORK": "🟡",
|
|
305
|
+
POOR: "🔴",
|
|
306
|
+
};
|
|
307
|
+
function formatAuditSlack(results) {
|
|
308
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
309
|
+
const good = results.filter((r) => r.rating === "GOOD").length;
|
|
310
|
+
const needsWork = results.filter((r) => r.rating === "NEEDS-WORK").length;
|
|
311
|
+
const poor = results.filter((r) => r.rating === "POOR").length;
|
|
312
|
+
const avgScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
|
|
313
|
+
const lines = [
|
|
314
|
+
`📋 *봇 워크스페이스 Audit* — ${date}`,
|
|
315
|
+
"",
|
|
316
|
+
];
|
|
317
|
+
for (const r of results) {
|
|
318
|
+
const emoji = RATING_EMOJI[r.rating] ?? "⚪";
|
|
319
|
+
const failed = r.checks.filter((c) => !c.passed);
|
|
320
|
+
const failInfo = failed.length > 0
|
|
321
|
+
? ` — missing: ${failed.map((c) => c.name).join(", ")}`
|
|
322
|
+
: "";
|
|
323
|
+
lines.push(`${emoji} *${r.botId}* ${r.score}% ${r.rating}${failInfo}`);
|
|
324
|
+
}
|
|
325
|
+
lines.push("");
|
|
326
|
+
lines.push(`${results.length}개 봇 | 평균 ${avgScore}% | GOOD: ${good} / NEEDS-WORK: ${needsWork} / POOR: ${poor}`);
|
|
327
|
+
return lines.join("\n");
|
|
328
|
+
}
|
|
329
|
+
// ============================================================
|
|
330
|
+
// DB storage
|
|
331
|
+
// ============================================================
|
|
332
|
+
async function storeAuditResults(results, client) {
|
|
333
|
+
const runId = (0, crypto_1.randomUUID)();
|
|
334
|
+
for (const r of results) {
|
|
335
|
+
await client.query(`INSERT INTO semo.bot_workspace_audits (bot_id, run_id, rating, score, checks)
|
|
336
|
+
VALUES ($1, $2, $3, $4, $5)`, [r.botId, runId, r.rating, r.score, JSON.stringify(r.checks)]);
|
|
337
|
+
}
|
|
338
|
+
}
|