crewx 0.8.1 → 0.8.2-rc.2
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 +268 -268
- package/bin/cli-commands.js +34 -0
- package/bin/crewx-lib.js +213 -108
- package/bin/crewx-ui.js +83 -83
- package/bin/crewx.js +219 -147
- package/bin/launcher-flags.js +29 -0
- package/bin/package.json +1 -1
- package/dist/assets/MarketPage-DptjaFpT.js +36 -0
- package/dist/assets/{PromptTab-DVKc7hJY.js → PromptTab-DZha2_v1.js} +1 -1
- package/dist/assets/{_baseUniq-wjlVo2E6.js → _baseUniq-jd6NubI3.js} +1 -1
- package/dist/assets/{arc-BfPgRtzW.js → arc-C2te3-8P.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-ewcueFAG.js → architectureDiagram-Q4EWVU46-CbIQua02.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-TxlbbvKn.js → blockDiagram-DXYQGD6D-Cg7qkpSM.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-C1lT_bl_.js → c4Diagram-AHTNJAMY-BkffDY1F.js} +1 -1
- package/dist/assets/channel-beae0DeI.js +1 -0
- package/dist/assets/chatgpt-logo-dark.svg +15 -15
- package/dist/assets/chatgpt-logo.svg +15 -15
- package/dist/assets/{chunk-4BX2VUAB-C41j2mCL.js → chunk-4BX2VUAB-BxHe9wPE.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-HNNsUbz0.js → chunk-4TB4RGXK--f1tN90O.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-qtCgO0r2.js → chunk-55IACEB6-B5QXfPXQ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BSnDYtsd.js → chunk-EDXVE4YY-DqKhblOg.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DyHRLQqX.js → chunk-FMBD7UC4-DZ1w_G02.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CCjfi6WS.js → chunk-OYMX7WX6-BqAgQpv8.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-COLty8kd.js → chunk-QZHKN3VN-DPqnGqVi.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-CHUUnGeN.js → chunk-YZCP3GAM-x-ZYSQLd.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BquWrs1y.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BquWrs1y.js +1 -0
- package/dist/assets/clone-C9wSPtDN.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CSip-V2g.js → cose-bilkent-S5V4N54A-4VCNRP-E.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DkdpnWhv.js → dagre-KV5264BT-DucVi1QS.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-PH4qc6PV.js → diagram-5BDNPKRD-ChdRA8bE.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cg5xZcjx.js → diagram-G4DWMVQ6-B1-97yHr.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-soKmeTCW.js → diagram-MMDJMWI5-CQs3cO7G.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Daq5Mihu.js → diagram-TYMM5635-CuNCxDfO.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-kr2OtY0Y.js → erDiagram-SMLLAGMA-DdR8v8g-.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DQZCb8gm.js → flowDiagram-DWJPFMVM-Dt02upId.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BHkn485T.js → ganttDiagram-T4ZO3ILL-cG_k9VOa.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-FaCyYFmC.js → gitGraphDiagram-UUTBAWPF-Dz1DjhKq.js} +1 -1
- package/dist/assets/{graph-BVJlrP6V.js → graph-CF2NtM33.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-DJOWkKdM.js → infoDiagram-42DDH7IO-tQWKrYM6.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-VfpvNaIf.js → ishikawaDiagram-UXIWVN3A-CLuUMkF0.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CPzsak-v.js → journeyDiagram-VCZTEJTY-a6JenLCk.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DFqLDBU0.js → kanban-definition-6JOO6SKY-rqOxTzYb.js} +1 -1
- package/dist/assets/{layout-CCSbNPHm.js → layout-Dvic1Hpy.js} +1 -1
- package/dist/assets/{linear-C4T7PCKE.js → linear-CfMV1S6a.js} +1 -1
- package/dist/assets/main-05K4ggqd.css +10 -0
- package/dist/assets/main-CCM1gtr8.js +1165 -0
- package/dist/assets/{min-CGQNEYGh.js → min-GpF3DZux.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-AuU1EqwS.js → mindmap-definition-QFDTVHPH-Cg80z0Jx.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CopkCZwp.js → pieDiagram-DEJITSTG-BrtK7lAq.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-lMKrSv_t.js → quadrantDiagram-34T5L4WZ-BL2txAAS.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-dWUpHOFb.js → requirementDiagram-MS252O5E-Co3wpBnu.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-C8UQx9Bb.js → sankeyDiagram-XADWPNL6-B4KJXdQ4.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-CUVNIItJ.js → sequenceDiagram-FGHM5R23-xs5OuzvV.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-Ct0GamGl.js → stateDiagram-FHFEXIEX-bbEP20JD.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-XYh9U1A7.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-ul8Po7f7.js → timeline-definition-GMOUNBTQ-CTc2wVwC.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-B4AOWQnP.js → vennDiagram-DHZGUBPP-oJpXott7.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-Dr7Wp3AJ.js → wardley-RL74JXVD-aTmOXkKh.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ck70faXX.js → wardleyDiagram-NUSXRM2D-C83SOkig.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Bsy5-cNt.js → xychartDiagram-5P7HB3ND-BScws0tF.js} +1 -1
- package/dist/index.html +13 -13
- package/dist-electron/main.js +153 -116
- package/dist-electron/overlay.js +102 -65
- package/dist-electron/package.json +1 -0
- package/dist-electron/preload.js +8 -8
- package/dist-server/bootstrap/crewx-server.js +19 -10
- package/dist-server/domain/agent/agent.service.js +12 -0
- package/dist-server/domain/agent/dto/update-agent.dto.js +20 -1
- package/dist-server/domain/mcp/crewx-tool.factory.js +44 -113
- package/dist-server/domain/mcp/mcp.module.js +2 -0
- package/dist-server/domain/mcp/mcp.service.js +37 -12
- package/dist-server/domain/message/message.service.js +21 -13
- package/dist-server/domain/skill/skill.service.js +63 -43
- package/dist-server/domain/task/task.module.js +2 -0
- package/dist-server/domain/task/task.service.js +17 -10
- package/dist-server/domain/thread/dto/update-thread.dto.js +23 -0
- package/dist-server/domain/thread/thread.controller.js +16 -0
- package/dist-server/domain/thread/thread.service.js +9 -0
- package/dist-server/main.js +1 -1
- package/dist-server/modules/crewx.module.js +16 -1
- package/dist-server/repository/box.repository.js +20 -20
- package/dist-server/repository/project.repository.js +13 -13
- package/dist-server/repository/request-log.repository.js +10 -10
- package/dist-server/repository/task.repository.js +72 -72
- package/dist-server/repository/thread.repository.js +78 -58
- package/package.json +6 -6
- package/packages/cli/dist/bootstrap/crewx-cli.js +12 -0
- package/packages/cli/dist/commands/agent.js +23 -23
- package/packages/cli/dist/commands/init.js +19 -19
- package/packages/cli/dist/commands/parse-common-flags.d.ts +19 -3
- package/packages/cli/dist/commands/parse-common-flags.js +46 -6
- package/packages/cli/dist/commands/registry.d.ts +13 -0
- package/packages/cli/dist/commands/registry.js +29 -0
- package/packages/cli/dist/commands/task-db.js +7 -7
- package/packages/cli/dist/examples/deny-secrets-plugin.d.ts +22 -0
- package/packages/cli/dist/examples/deny-secrets-plugin.js +40 -0
- package/packages/cli/dist/main.js +134 -68
- package/packages/cli/dist/plugins/examples/echo-hook.d.ts +24 -0
- package/packages/cli/dist/plugins/examples/echo-hook.js +60 -0
- package/packages/cli/dist/plugins/examples/verify-echo-hook.d.ts +8 -0
- package/packages/cli/dist/plugins/examples/verify-echo-hook.js +47 -0
- package/packages/cli/dist/plugins/sqlite-tracing.d.ts +11 -0
- package/packages/cli/dist/plugins/sqlite-tracing.js +19 -0
- package/packages/cli/dist/schema/tasks.d.ts +7 -0
- package/packages/cli/dist/schema/tasks.js +48 -0
- package/packages/cli/package.json +52 -52
- package/scripts/analyze-task-logs.mjs +569 -0
- package/scripts/build-manual.mjs +266 -266
- package/scripts/emit-dist-server-package-json.mjs +7 -7
- package/scripts/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/scripts/postinstall.mjs +44 -44
- package/scripts/smoke-tarball.mjs +285 -285
- package/scripts/snapshot-msg-list.sh +52 -52
- package/server.js +167 -164
- package/dist/assets/MarketPage-Dwsg6K-B.js +0 -31
- package/dist/assets/channel-BP4PNMmz.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Upr3UAcM.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-Upr3UAcM.js +0 -1
- package/dist/assets/clone-B8BP7ReZ.js +0 -1
- package/dist/assets/main-CELBpK6r.js +0 -1166
- package/dist/assets/main-CmP-VosD.css +0 -10
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-CFBLQDEx.js +0 -1
- package/dist-server/domain/task/dto/project-usage.dto.js +0 -38
- package/dist-server/domain/thread/dto/send-message.dto.js +0 -10
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PM 조사 도구 — tasks.logs 컬럼 통계 분석기 (임시/재활용 가능)
|
|
3
|
+
// 제품 코드 아님. scripts/ 하위 조사용.
|
|
4
|
+
//
|
|
5
|
+
// 사용 예:
|
|
6
|
+
// node scripts/analyze-task-logs.mjs
|
|
7
|
+
// node scripts/analyze-task-logs.mjs --preset failure-analysis
|
|
8
|
+
// node scripts/analyze-task-logs.mjs --since 2026-04-01 --until 2026-04-24
|
|
9
|
+
// node scripts/analyze-task-logs.mjs --filter status=failed --group-by event.type
|
|
10
|
+
// node scripts/analyze-task-logs.mjs --preset failure-analysis --sample 20 --output md,json,console
|
|
11
|
+
|
|
12
|
+
import Database from 'better-sqlite3';
|
|
13
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join, dirname, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const REPO_ROOT = resolve(__dirname, '..');
|
|
20
|
+
|
|
21
|
+
// ─── CLI 파싱 ───────────────────────────────────────────────────────────────
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = {
|
|
24
|
+
db: join(homedir(), '.crewx/crewx.db'),
|
|
25
|
+
since: null, // ISO date or YYYY-MM-DD; default: 14d ago
|
|
26
|
+
until: null, // default: now
|
|
27
|
+
filter: [], // array of "col=val" or raw SQL WHERE fragment
|
|
28
|
+
groupBy: [],
|
|
29
|
+
sample: 10,
|
|
30
|
+
preset: null,
|
|
31
|
+
output: ['console'],
|
|
32
|
+
outputDir: join(REPO_ROOT, 'docs/qa-reports'),
|
|
33
|
+
};
|
|
34
|
+
for (let i = 2; i < argv.length; i++) {
|
|
35
|
+
const a = argv[i];
|
|
36
|
+
const next = () => argv[++i];
|
|
37
|
+
switch (a) {
|
|
38
|
+
case '--db': args.db = next(); break;
|
|
39
|
+
case '--since': args.since = next(); break;
|
|
40
|
+
case '--until': args.until = next(); break;
|
|
41
|
+
case '--filter': args.filter.push(next()); break;
|
|
42
|
+
case '--group-by': args.groupBy.push(next()); break;
|
|
43
|
+
case '--sample': args.sample = parseInt(next(), 10); break;
|
|
44
|
+
case '--preset': args.preset = next(); break;
|
|
45
|
+
case '--output': args.output = next().split(',').map(s => s.trim()); break;
|
|
46
|
+
case '--output-dir': args.outputDir = next(); break;
|
|
47
|
+
case '-h':
|
|
48
|
+
case '--help': printHelp(); process.exit(0);
|
|
49
|
+
default: console.error(`Unknown arg: ${a}`); process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// defaults
|
|
53
|
+
if (!args.since) {
|
|
54
|
+
const d = new Date(); d.setDate(d.getDate() - 14);
|
|
55
|
+
args.since = d.toISOString();
|
|
56
|
+
}
|
|
57
|
+
if (!args.until) args.until = new Date().toISOString();
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printHelp() {
|
|
62
|
+
console.log(`
|
|
63
|
+
PM Task Logs Analyzer
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
node scripts/analyze-task-logs.mjs [options]
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
--db <path> SQLite DB path (default: ~/.crewx/crewx.db)
|
|
70
|
+
--since <date> Include tasks started at/after date (default: 14 days ago)
|
|
71
|
+
--until <date> Include tasks started at/before date (default: now)
|
|
72
|
+
--filter <expr> WHERE fragment (repeatable). e.g. status=failed, agent_id=core_sqa
|
|
73
|
+
--group-by <key> Grouping key (repeatable). Supported: event.type, event.level, provider, agent_id
|
|
74
|
+
--sample <n> Drill-down sample count per bucket (default: 10)
|
|
75
|
+
--preset <name> Preset bundle: failure-analysis | event-catalog
|
|
76
|
+
--output <csv> Comma list of: console, json, md (default: console)
|
|
77
|
+
--output-dir <path> Output directory for md/json (default: docs/qa-reports)
|
|
78
|
+
-h, --help Print this help
|
|
79
|
+
|
|
80
|
+
Presets:
|
|
81
|
+
failure-analysis — 실패 태스크 이벤트 패턴 + 카탈로그 v2 매칭률 + drill-down
|
|
82
|
+
event-catalog — provider별 관찰된 이벤트 타입 전수 목록 (중립 분석)
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Provider 감지 (stdout 이벤트 구조 기반 — ground truth) ────────────────────
|
|
87
|
+
// 외부 파일 의존성 없음. DB 자체만 보고 판단.
|
|
88
|
+
function detectProvider(stdoutLines) {
|
|
89
|
+
const types = new Set();
|
|
90
|
+
for (const line of stdoutLines) {
|
|
91
|
+
try {
|
|
92
|
+
const p = JSON.parse(line);
|
|
93
|
+
if (p.type) types.add(p.type);
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
// Codex: turn.*/thread.*/item.* 전용
|
|
97
|
+
if (types.has('turn.started') || types.has('turn.failed') || types.has('thread.started')) return 'codex';
|
|
98
|
+
// Copilot: assistant.* 델타 + tool.execution_*
|
|
99
|
+
if (types.has('assistant.message_delta') || types.has('tool.execution_complete') || types.has('tool.execution_start')) return 'copilot';
|
|
100
|
+
// OpenCode: step_start + step_finish (part 객체 포함)
|
|
101
|
+
if (types.has('step_start') && types.has('step_finish')) return 'opencode';
|
|
102
|
+
// Claude Code: system + result (둘 다 있는 경우)
|
|
103
|
+
if (types.has('system') && types.has('result')) return 'claude';
|
|
104
|
+
// Gemini native: init + message (type 필드만 보고)
|
|
105
|
+
if (types.has('init') && types.has('message')) return 'gemini';
|
|
106
|
+
// Claude 단독 (짧게 실패) — assistant + result 조합
|
|
107
|
+
if (types.has('result') && types.has('assistant')) return 'claude';
|
|
108
|
+
return null; // 감지 불가 (로그 없거나 너무 짧음)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── 카탈로그 v2 기반 extractFailure (설계서 참조) ─────────────────────────────
|
|
112
|
+
// tier: primary(확정) / secondary(noise 허용) / tertiary(rate limit 등 약한 신호)
|
|
113
|
+
function extractClaudeLikeFailure(p) {
|
|
114
|
+
if (p.type === 'result' && p.is_error === true) {
|
|
115
|
+
return { message: String(p.result || p.error || 'Task failed'), tier: 'primary' };
|
|
116
|
+
}
|
|
117
|
+
if (p.type === 'rate_limit_event') {
|
|
118
|
+
const info = p.rate_limit_info;
|
|
119
|
+
if (info?.status === 'rejected') return { message: 'Rate limit rejected', tier: 'tertiary' };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const FAILURE_EXTRACTORS = {
|
|
124
|
+
codex(p) {
|
|
125
|
+
if (p.type === 'turn.failed' && p.error?.message) {
|
|
126
|
+
return { message: String(p.error.message), tier: 'primary' };
|
|
127
|
+
}
|
|
128
|
+
if (p.type === 'error' && p.message) {
|
|
129
|
+
return { message: String(p.message), tier: 'secondary' };
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
},
|
|
133
|
+
claude: extractClaudeLikeFailure,
|
|
134
|
+
copilot: extractClaudeLikeFailure,
|
|
135
|
+
gemini(p) {
|
|
136
|
+
if (p.type === 'error' && p.message) return { message: String(p.message), tier: 'primary' };
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
opencode(p) {
|
|
140
|
+
if (p.type === 'step_finish' && p.part?.reason && p.part.reason !== 'stop') {
|
|
141
|
+
return { message: `OpenCode step stopped: ${p.part.reason}`, tier: 'primary' };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function pickFailureMessage(stdoutLines, provider) {
|
|
148
|
+
const extractor = FAILURE_EXTRACTORS[provider];
|
|
149
|
+
if (!extractor) return { message: null, tier: null };
|
|
150
|
+
let primary = null, lastSecondary = null, lastTertiary = null;
|
|
151
|
+
for (const line of stdoutLines) {
|
|
152
|
+
let p;
|
|
153
|
+
try { p = JSON.parse(line); } catch { continue; }
|
|
154
|
+
const sig = extractor(p);
|
|
155
|
+
if (!sig) continue;
|
|
156
|
+
if (sig.tier === 'primary' && primary == null) primary = sig.message;
|
|
157
|
+
else if (sig.tier === 'secondary') lastSecondary = sig.message;
|
|
158
|
+
else if (sig.tier === 'tertiary') lastTertiary = sig.message;
|
|
159
|
+
}
|
|
160
|
+
if (primary != null) return { message: primary, tier: 'primary' };
|
|
161
|
+
if (lastSecondary != null) return { message: lastSecondary, tier: 'secondary' };
|
|
162
|
+
if (lastTertiary != null) return { message: lastTertiary, tier: 'tertiary' };
|
|
163
|
+
return { message: null, tier: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── 기본 통계 집계 ──────────────────────────────────────────────────────────
|
|
167
|
+
function analyze({ rows, sampleCount, groupBy }) {
|
|
168
|
+
const stats = {
|
|
169
|
+
totalTasks: rows.length,
|
|
170
|
+
statusDist: {},
|
|
171
|
+
providerDist: {},
|
|
172
|
+
eventLevelTotals: { stdout: 0, stderr: 0, info: 0, other: 0 },
|
|
173
|
+
eventTypesByProvider: {}, // provider → type → count
|
|
174
|
+
eventCardinality: {}, // provider → Set<type>
|
|
175
|
+
tasksPerProvider: {}, // provider → count
|
|
176
|
+
tasksWithStdoutJson: {}, // provider → count
|
|
177
|
+
tasksWithStderr: {}, // provider → count
|
|
178
|
+
jsonParseStats: { ok: 0, fail: 0 },
|
|
179
|
+
eventsPerTaskDist: [], // numeric array → mean/median/max
|
|
180
|
+
stderrFirstLineDist: {}, // provider → first-stderr-line → count
|
|
181
|
+
durationsByStatus: {}, // status → duration_ms[]
|
|
182
|
+
// failure-specific
|
|
183
|
+
failureMatch: { // provider → {primary, secondary, tertiary, none, total}
|
|
184
|
+
},
|
|
185
|
+
unmatchedFailureSamples: {}, // provider → sample[]
|
|
186
|
+
extractedMessages: {}, // provider → {message: count}
|
|
187
|
+
groupBy: {}, // custom group-by
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
// status dist
|
|
192
|
+
stats.statusDist[row.status] = (stats.statusDist[row.status] || 0) + 1;
|
|
193
|
+
|
|
194
|
+
// duration
|
|
195
|
+
if (row.duration_ms != null) {
|
|
196
|
+
(stats.durationsByStatus[row.status] ||= []).push(row.duration_ms);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// logs
|
|
200
|
+
let logs = [];
|
|
201
|
+
if (row.logs) {
|
|
202
|
+
try { logs = JSON.parse(row.logs); if (!Array.isArray(logs)) logs = []; } catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const stdoutLines = [];
|
|
206
|
+
let hasStderr = false;
|
|
207
|
+
let firstStderr = null;
|
|
208
|
+
for (const entry of logs) {
|
|
209
|
+
const level = entry.level || 'other';
|
|
210
|
+
stats.eventLevelTotals[level] = (stats.eventLevelTotals[level] || 0) + 1;
|
|
211
|
+
if (level === 'stdout') {
|
|
212
|
+
stdoutLines.push(entry.message);
|
|
213
|
+
} else if (level === 'stderr') {
|
|
214
|
+
hasStderr = true;
|
|
215
|
+
if (firstStderr == null) firstStderr = (entry.message || '').slice(0, 120);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// provider 감지 — stdout 이벤트 구조로만 (DB ground truth)
|
|
219
|
+
const provider = detectProvider(stdoutLines) || 'unknown';
|
|
220
|
+
stats.providerDist[provider] = (stats.providerDist[provider] || 0) + 1;
|
|
221
|
+
stats.tasksPerProvider[provider] = (stats.tasksPerProvider[provider] || 0) + 1;
|
|
222
|
+
|
|
223
|
+
if (stdoutLines.length > 0) {
|
|
224
|
+
stats.tasksWithStdoutJson[provider] = (stats.tasksWithStdoutJson[provider] || 0) + 1;
|
|
225
|
+
}
|
|
226
|
+
if (hasStderr) {
|
|
227
|
+
stats.tasksWithStderr[provider] = (stats.tasksWithStderr[provider] || 0) + 1;
|
|
228
|
+
if (firstStderr) {
|
|
229
|
+
(stats.stderrFirstLineDist[provider] ||= {});
|
|
230
|
+
stats.stderrFirstLineDist[provider][firstStderr] = (stats.stderrFirstLineDist[provider][firstStderr] || 0) + 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
stats.eventsPerTaskDist.push(logs.length);
|
|
234
|
+
|
|
235
|
+
// event type集계
|
|
236
|
+
(stats.eventTypesByProvider[provider] ||= {});
|
|
237
|
+
(stats.eventCardinality[provider] ||= new Set());
|
|
238
|
+
for (const line of stdoutLines) {
|
|
239
|
+
let parsed;
|
|
240
|
+
try { parsed = JSON.parse(line); stats.jsonParseStats.ok++; } catch { stats.jsonParseStats.fail++; continue; }
|
|
241
|
+
const t = parsed.type || '_no_type';
|
|
242
|
+
stats.eventTypesByProvider[provider][t] = (stats.eventTypesByProvider[provider][t] || 0) + 1;
|
|
243
|
+
stats.eventCardinality[provider].add(t);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// failure-specific (status=failed)
|
|
247
|
+
if (row.status === 'failed') {
|
|
248
|
+
const errMsg = normalizeError(row.error);
|
|
249
|
+
const isUserCancelled = errMsg && /중지|cancel|abort/i.test(errMsg);
|
|
250
|
+
(stats.failureMatch[provider] ||= { primary: 0, secondary: 0, tertiary: 0, none: 0, userCancelled: 0, total: 0 });
|
|
251
|
+
const m = stats.failureMatch[provider];
|
|
252
|
+
m.total++;
|
|
253
|
+
if (isUserCancelled) { m.userCancelled++; continue; }
|
|
254
|
+
|
|
255
|
+
const { message, tier } = pickFailureMessage(stdoutLines, provider);
|
|
256
|
+
if (tier) {
|
|
257
|
+
m[tier]++;
|
|
258
|
+
// record unique extracted messages
|
|
259
|
+
(stats.extractedMessages[provider] ||= {});
|
|
260
|
+
const keyMsg = (message || '').slice(0, 200);
|
|
261
|
+
stats.extractedMessages[provider][keyMsg] = (stats.extractedMessages[provider][keyMsg] || 0) + 1;
|
|
262
|
+
} else {
|
|
263
|
+
m.none++;
|
|
264
|
+
// drill-down: capture sample
|
|
265
|
+
(stats.unmatchedFailureSamples[provider] ||= []);
|
|
266
|
+
if (stats.unmatchedFailureSamples[provider].length < sampleCount) {
|
|
267
|
+
const lastEvents = stdoutLines.slice(-5);
|
|
268
|
+
stats.unmatchedFailureSamples[provider].push({
|
|
269
|
+
id: row.id,
|
|
270
|
+
agent: row.agent_id,
|
|
271
|
+
model: row.model,
|
|
272
|
+
errorField: errMsg ? errMsg.slice(0, 200) : null,
|
|
273
|
+
lastStdoutEvents: lastEvents.map(l => l.slice(0, 300)),
|
|
274
|
+
stdoutEventCount: stdoutLines.length,
|
|
275
|
+
firstStderrLine: firstStderr,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// custom group-by
|
|
282
|
+
for (const key of groupBy) {
|
|
283
|
+
const bucket = resolveGroupKey(key, { row, provider, logs, stdoutLines });
|
|
284
|
+
if (bucket == null) continue;
|
|
285
|
+
(stats.groupBy[key] ||= {});
|
|
286
|
+
stats.groupBy[key][bucket] = (stats.groupBy[key][bucket] || 0) + 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Convert Set to count
|
|
291
|
+
for (const p of Object.keys(stats.eventCardinality)) {
|
|
292
|
+
stats.eventCardinality[p] = stats.eventCardinality[p].size;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Summaries
|
|
296
|
+
stats.eventsPerTaskStats = numericSummary(stats.eventsPerTaskDist);
|
|
297
|
+
delete stats.eventsPerTaskDist;
|
|
298
|
+
for (const [status, arr] of Object.entries(stats.durationsByStatus)) {
|
|
299
|
+
stats.durationsByStatus[status] = numericSummary(arr);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return stats;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function normalizeError(errField) {
|
|
306
|
+
if (!errField) return null;
|
|
307
|
+
try { const p = JSON.parse(errField); return p.message || errField; } catch { return errField; }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function resolveGroupKey(key, ctx) {
|
|
311
|
+
if (key === 'agent_id') return ctx.row.agent_id;
|
|
312
|
+
if (key === 'provider') return ctx.provider;
|
|
313
|
+
if (key === 'status') return ctx.row.status;
|
|
314
|
+
if (key === 'model') return ctx.row.model;
|
|
315
|
+
if (key === 'event.type') {
|
|
316
|
+
// counts per unique event type — returns array... flatten: return null, handled elsewhere
|
|
317
|
+
// For simplicity here, pick first event type
|
|
318
|
+
for (const line of ctx.stdoutLines) {
|
|
319
|
+
try { const p = JSON.parse(line); if (p.type) return p.type; } catch {}
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
if (key === 'event.level') {
|
|
324
|
+
const levels = new Set(ctx.logs.map(l => l.level || 'other'));
|
|
325
|
+
return [...levels].sort().join('+');
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function numericSummary(arr) {
|
|
331
|
+
if (!arr.length) return { count: 0, mean: 0, median: 0, max: 0, min: 0 };
|
|
332
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
333
|
+
const sum = sorted.reduce((s, v) => s + v, 0);
|
|
334
|
+
return {
|
|
335
|
+
count: sorted.length,
|
|
336
|
+
mean: Math.round(sum / sorted.length),
|
|
337
|
+
median: sorted[Math.floor(sorted.length / 2)],
|
|
338
|
+
max: sorted[sorted.length - 1],
|
|
339
|
+
min: sorted[0],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── WHERE 절 조립 ────────────────────────────────────────────────────────────
|
|
344
|
+
function buildWhere(filters, since, until) {
|
|
345
|
+
const clauses = [];
|
|
346
|
+
const params = {};
|
|
347
|
+
// time filter
|
|
348
|
+
if (since) { clauses.push('started_at >= @since'); params.since = since; }
|
|
349
|
+
if (until) { clauses.push('started_at <= @until'); params.until = until; }
|
|
350
|
+
// filters
|
|
351
|
+
for (let i = 0; i < filters.length; i++) {
|
|
352
|
+
const f = filters[i];
|
|
353
|
+
const m = f.match(/^(\w+)\s*=\s*(.+)$/);
|
|
354
|
+
if (m) {
|
|
355
|
+
const col = m[1]; const val = m[2].replace(/^['"]|['"]$/g, '');
|
|
356
|
+
clauses.push(`${col} = @f${i}`); params[`f${i}`] = val;
|
|
357
|
+
} else {
|
|
358
|
+
clauses.push(`(${f})`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ─── 출력 포맷터 ─────────────────────────────────────────────────────────────
|
|
365
|
+
function renderConsole(stats, args) {
|
|
366
|
+
const out = [];
|
|
367
|
+
out.push(`\n━━━ Task Logs Analysis ━━━`);
|
|
368
|
+
out.push(`기간: ${args.since} ~ ${args.until}`);
|
|
369
|
+
out.push(`필터: ${args.filter.length ? args.filter.join(' | ') : '(없음)'}`);
|
|
370
|
+
out.push(`총 태스크: ${stats.totalTasks}`);
|
|
371
|
+
out.push('');
|
|
372
|
+
out.push(`[상태 분포]`);
|
|
373
|
+
for (const [k, v] of Object.entries(stats.statusDist).sort((a,b) => b[1]-a[1])) {
|
|
374
|
+
out.push(` ${k.padEnd(12)} ${v}`);
|
|
375
|
+
}
|
|
376
|
+
out.push('');
|
|
377
|
+
out.push(`[Provider 분포]`);
|
|
378
|
+
for (const [k, v] of Object.entries(stats.providerDist).sort((a,b) => b[1]-a[1])) {
|
|
379
|
+
out.push(` ${k.padEnd(12)} ${v}`);
|
|
380
|
+
}
|
|
381
|
+
out.push('');
|
|
382
|
+
out.push(`[이벤트 level 총량]`);
|
|
383
|
+
for (const [k, v] of Object.entries(stats.eventLevelTotals)) out.push(` ${k.padEnd(8)} ${v}`);
|
|
384
|
+
out.push('');
|
|
385
|
+
out.push(`[태스크당 이벤트 수] mean=${stats.eventsPerTaskStats.mean} median=${stats.eventsPerTaskStats.median} max=${stats.eventsPerTaskStats.max}`);
|
|
386
|
+
out.push(`[stdout JSON 파싱] ok=${stats.jsonParseStats.ok} fail=${stats.jsonParseStats.fail}`);
|
|
387
|
+
|
|
388
|
+
out.push(`\n[Provider × 이벤트 타입 카디널리티]`);
|
|
389
|
+
for (const [p, n] of Object.entries(stats.eventCardinality)) {
|
|
390
|
+
out.push(` ${p.padEnd(12)} ${n} unique types`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (Object.keys(stats.failureMatch).length) {
|
|
394
|
+
out.push(`\n[실패 태스크 — 카탈로그 v2 매칭률]`);
|
|
395
|
+
out.push(` Provider total primary secondary tertiary none userCancelled improve%`);
|
|
396
|
+
for (const [p, m] of Object.entries(stats.failureMatch).sort((a,b) => b[1].total-a[1].total)) {
|
|
397
|
+
const improvable = m.total - m.userCancelled;
|
|
398
|
+
const matched = m.primary + m.secondary + m.tertiary;
|
|
399
|
+
const pct = improvable > 0 ? ((matched / improvable) * 100).toFixed(0) : 0;
|
|
400
|
+
out.push(` ${p.padEnd(12)} ${String(m.total).padStart(5)} ${String(m.primary).padStart(7)} ${String(m.secondary).padStart(9)} ${String(m.tertiary).padStart(8)} ${String(m.none).padStart(4)} ${String(m.userCancelled).padStart(13)} ${pct}%`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (args.preset === 'failure-analysis' || args.preset === 'event-catalog') {
|
|
405
|
+
out.push(`\n[Provider × Event Type 빈도 (Top 15)]`);
|
|
406
|
+
for (const [p, types] of Object.entries(stats.eventTypesByProvider)) {
|
|
407
|
+
out.push(` --- ${p} ---`);
|
|
408
|
+
const sorted = Object.entries(types).sort((a,b) => b[1]-a[1]).slice(0, 15);
|
|
409
|
+
for (const [t, c] of sorted) out.push(` ${t.padEnd(35)} ${c}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (args.preset === 'failure-analysis' && Object.keys(stats.extractedMessages).length) {
|
|
414
|
+
out.push(`\n[추출된 메시지 unique 빈도 (Top 10 per provider)]`);
|
|
415
|
+
for (const [p, msgs] of Object.entries(stats.extractedMessages)) {
|
|
416
|
+
out.push(` --- ${p} ---`);
|
|
417
|
+
const sorted = Object.entries(msgs).sort((a,b) => b[1]-a[1]).slice(0, 10);
|
|
418
|
+
for (const [m, c] of sorted) out.push(` [${String(c).padStart(3)}] ${m.slice(0, 140)}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (args.preset === 'failure-analysis' && Object.keys(stats.unmatchedFailureSamples).length) {
|
|
423
|
+
out.push(`\n[비매칭 실패 샘플 drill-down (max ${args.sample} per provider)]`);
|
|
424
|
+
for (const [p, samples] of Object.entries(stats.unmatchedFailureSamples)) {
|
|
425
|
+
out.push(` --- ${p} (${samples.length} samples) ---`);
|
|
426
|
+
for (const s of samples.slice(0, 5)) {
|
|
427
|
+
out.push(` [${s.id}] agent=${s.agent} model=${s.model || '-'} events=${s.stdoutEventCount}`);
|
|
428
|
+
out.push(` errorField: ${s.errorField || '-'}`);
|
|
429
|
+
out.push(` firstStderr: ${s.firstStderrLine || '-'}`);
|
|
430
|
+
const last = s.lastStdoutEvents[s.lastStdoutEvents.length - 1];
|
|
431
|
+
if (last) out.push(` lastEvent: ${last.slice(0, 160)}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return out.join('\n');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function renderMarkdown(stats, args) {
|
|
440
|
+
const lines = [];
|
|
441
|
+
lines.push(`# Task Logs Analysis — ${args.preset || 'custom'}`);
|
|
442
|
+
lines.push('');
|
|
443
|
+
lines.push(`- **기간**: ${args.since} ~ ${args.until}`);
|
|
444
|
+
lines.push(`- **필터**: ${args.filter.length ? args.filter.join(', ') : '(없음)'}`);
|
|
445
|
+
lines.push(`- **총 태스크**: ${stats.totalTasks}`);
|
|
446
|
+
lines.push(`- **생성**: ${new Date().toISOString()}`);
|
|
447
|
+
lines.push('');
|
|
448
|
+
lines.push(`## 상태 분포`);
|
|
449
|
+
lines.push('| status | count |');
|
|
450
|
+
lines.push('|---|---|');
|
|
451
|
+
for (const [k, v] of Object.entries(stats.statusDist).sort((a,b) => b[1]-a[1])) {
|
|
452
|
+
lines.push(`| ${k} | ${v} |`);
|
|
453
|
+
}
|
|
454
|
+
lines.push('');
|
|
455
|
+
lines.push(`## Provider 분포`);
|
|
456
|
+
lines.push('| provider | count |');
|
|
457
|
+
lines.push('|---|---|');
|
|
458
|
+
for (const [k, v] of Object.entries(stats.providerDist).sort((a,b) => b[1]-a[1])) {
|
|
459
|
+
lines.push(`| ${k} | ${v} |`);
|
|
460
|
+
}
|
|
461
|
+
lines.push('');
|
|
462
|
+
if (Object.keys(stats.failureMatch).length) {
|
|
463
|
+
lines.push(`## 실패 매칭률 (카탈로그 v2 기준)`);
|
|
464
|
+
lines.push('| provider | total | primary | secondary | tertiary | none | userCancelled | improve% |');
|
|
465
|
+
lines.push('|---|---|---|---|---|---|---|---|');
|
|
466
|
+
for (const [p, m] of Object.entries(stats.failureMatch).sort((a,b) => b[1].total-a[1].total)) {
|
|
467
|
+
const improvable = m.total - m.userCancelled;
|
|
468
|
+
const matched = m.primary + m.secondary + m.tertiary;
|
|
469
|
+
const pct = improvable > 0 ? ((matched / improvable) * 100).toFixed(0) : 0;
|
|
470
|
+
lines.push(`| ${p} | ${m.total} | ${m.primary} | ${m.secondary} | ${m.tertiary} | ${m.none} | ${m.userCancelled} | ${pct}% |`);
|
|
471
|
+
}
|
|
472
|
+
lines.push('');
|
|
473
|
+
}
|
|
474
|
+
lines.push(`## Provider × Event Type 빈도 (Top 15)`);
|
|
475
|
+
for (const [p, types] of Object.entries(stats.eventTypesByProvider)) {
|
|
476
|
+
lines.push(`### ${p}`);
|
|
477
|
+
lines.push('| event.type | count |');
|
|
478
|
+
lines.push('|---|---|');
|
|
479
|
+
const sorted = Object.entries(types).sort((a,b) => b[1]-a[1]).slice(0, 15);
|
|
480
|
+
for (const [t, c] of sorted) lines.push(`| \`${t}\` | ${c} |`);
|
|
481
|
+
lines.push('');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (Object.keys(stats.extractedMessages).length) {
|
|
485
|
+
lines.push(`## 추출된 메시지 unique 빈도`);
|
|
486
|
+
for (const [p, msgs] of Object.entries(stats.extractedMessages)) {
|
|
487
|
+
lines.push(`### ${p}`);
|
|
488
|
+
lines.push('| count | message |');
|
|
489
|
+
lines.push('|---|---|');
|
|
490
|
+
const sorted = Object.entries(msgs).sort((a,b) => b[1]-a[1]).slice(0, 15);
|
|
491
|
+
for (const [m, c] of sorted) lines.push(`| ${c} | \`${m.replace(/\|/g, '\\|').slice(0, 180)}\` |`);
|
|
492
|
+
lines.push('');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (Object.keys(stats.unmatchedFailureSamples).length) {
|
|
497
|
+
lines.push(`## 비매칭 실패 샘플 drill-down (${args.sample}개 per provider)`);
|
|
498
|
+
for (const [p, samples] of Object.entries(stats.unmatchedFailureSamples)) {
|
|
499
|
+
lines.push(`### ${p} (${samples.length} samples)`);
|
|
500
|
+
for (const s of samples) {
|
|
501
|
+
lines.push(`- **${s.id}** agent=\`${s.agent}\` model=\`${s.model || '-'}\` events=${s.stdoutEventCount}`);
|
|
502
|
+
lines.push(` - errorField: \`${(s.errorField || '-').replace(/`/g, "'")}\``);
|
|
503
|
+
lines.push(` - firstStderr: \`${(s.firstStderrLine || '-').replace(/`/g, "'")}\``);
|
|
504
|
+
if (s.lastStdoutEvents.length) {
|
|
505
|
+
lines.push(` - lastEvent: \`${s.lastStdoutEvents[s.lastStdoutEvents.length-1].slice(0, 200).replace(/`/g, "'")}\``);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
lines.push('');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (Object.keys(stats.stderrFirstLineDist).length) {
|
|
513
|
+
lines.push(`## stderr 첫 줄 분포 (provider별 Top 8)`);
|
|
514
|
+
for (const [p, dist] of Object.entries(stats.stderrFirstLineDist)) {
|
|
515
|
+
lines.push(`### ${p}`);
|
|
516
|
+
lines.push('| count | stderr first line |');
|
|
517
|
+
lines.push('|---|---|');
|
|
518
|
+
const sorted = Object.entries(dist).sort((a,b) => b[1]-a[1]).slice(0, 8);
|
|
519
|
+
for (const [l, c] of sorted) lines.push(`| ${c} | \`${l.replace(/\|/g, '\\|').replace(/`/g, "'").slice(0, 160)}\` |`);
|
|
520
|
+
lines.push('');
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return lines.join('\n');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ─── 메인 ────────────────────────────────────────────────────────────────────
|
|
528
|
+
function main() {
|
|
529
|
+
const args = parseArgs(process.argv);
|
|
530
|
+
|
|
531
|
+
// 프리셋 자동 필터/그룹 세팅
|
|
532
|
+
if (args.preset === 'failure-analysis' && !args.filter.some(f => f.startsWith('status='))) {
|
|
533
|
+
args.filter.push('status=failed');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const db = new Database(args.db, { readonly: true });
|
|
537
|
+
const { where, params } = buildWhere(args.filter, args.since, args.until);
|
|
538
|
+
const sql = `
|
|
539
|
+
SELECT id, agent_id, status, error, logs, started_at, completed_at, duration_ms, model
|
|
540
|
+
FROM tasks
|
|
541
|
+
${where}
|
|
542
|
+
`;
|
|
543
|
+
const rows = db.prepare(sql).all(params);
|
|
544
|
+
|
|
545
|
+
const stats = analyze({ rows, sampleCount: args.sample, groupBy: args.groupBy });
|
|
546
|
+
stats._meta = { since: args.since, until: args.until, preset: args.preset, filter: args.filter };
|
|
547
|
+
|
|
548
|
+
// 출력
|
|
549
|
+
if (args.output.includes('console')) {
|
|
550
|
+
console.log(renderConsole(stats, args));
|
|
551
|
+
}
|
|
552
|
+
if (args.output.includes('md') || args.output.includes('json')) {
|
|
553
|
+
if (!existsSync(args.outputDir)) mkdirSync(args.outputDir, { recursive: true });
|
|
554
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
555
|
+
const base = args.preset ? `task-logs-${args.preset}-${date}` : `task-logs-${date}`;
|
|
556
|
+
if (args.output.includes('md')) {
|
|
557
|
+
const p = join(args.outputDir, `${base}.md`);
|
|
558
|
+
writeFileSync(p, renderMarkdown(stats, args), 'utf8');
|
|
559
|
+
console.log(`\n📝 MD 리포트: ${p}`);
|
|
560
|
+
}
|
|
561
|
+
if (args.output.includes('json')) {
|
|
562
|
+
const p = join(args.outputDir, `${base}.json`);
|
|
563
|
+
writeFileSync(p, JSON.stringify(stats, null, 2), 'utf8');
|
|
564
|
+
console.log(`📊 JSON dump: ${p}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
main();
|