deuk-agent-rule 2.5.13 → 3.3.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/CHANGELOG.ko.md +74 -0
- package/CHANGELOG.md +138 -316
- package/README.ko.md +134 -154
- package/README.md +121 -153
- package/package.json +29 -7
- package/scripts/cli-args.mjs +87 -3
- package/scripts/cli-init-commands.mjs +1382 -223
- package/scripts/cli-init-logic.mjs +28 -16
- package/scripts/cli-prompts.mjs +13 -4
- package/scripts/cli-rule-compiler.mjs +44 -34
- package/scripts/cli-skill-commands.mjs +172 -0
- package/scripts/cli-telemetry-commands.mjs +429 -0
- package/scripts/cli-ticket-commands.mjs +1934 -161
- package/scripts/cli-ticket-index.mjs +298 -0
- package/scripts/cli-ticket-migration.mjs +320 -0
- package/scripts/cli-ticket-parser.mjs +207 -0
- package/scripts/cli-utils.mjs +381 -59
- package/scripts/cli.mjs +99 -19
- package/scripts/lint-md.mjs +247 -0
- package/scripts/lint-rules.mjs +143 -0
- package/scripts/merge-logic.mjs +13 -306
- package/scripts/plan-parser.mjs +53 -0
- package/templates/MODULE_RULE_TEMPLATE.md +11 -0
- package/templates/PROJECT_RULE.md +47 -0
- package/templates/TICKET_TEMPLATE.ko.md +21 -0
- package/templates/TICKET_TEMPLATE.md +21 -0
- package/templates/rules.d/deukcontext-mcp.md +31 -0
- package/templates/rules.d/platform-coexistence.md +29 -0
- package/templates/skills/context-recall/SKILL.md +25 -0
- package/templates/skills/generated-file-guard/SKILL.md +25 -0
- package/templates/skills/safe-refactor/SKILL.md +25 -0
- package/bundle/.cursorrules +0 -11
- package/bundle/AGENTS.md +0 -146
- package/bundle/gemini.md +0 -26
- package/bundle/rules/delivery-and-parallel-work.mdc +0 -26
- package/bundle/rules/git-commit.mdc +0 -24
- package/bundle/rules/multi-ai-workflow.mdc +0 -104
- package/bundle/rules.d/core-workflow.md +0 -48
- package/bundle/rules.d/deukrag-mcp.md +0 -37
- package/bundle/templates/MODULE_RULE_TEMPLATE.md +0 -24
- package/bundle/templates/TICKET_TEMPLATE.md +0 -58
- package/scripts/cli-ticket-logic.mjs +0 -568
- package/scripts/sync-bundle.mjs +0 -77
- package/scripts/sync-oss.mjs +0 -126
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { loadInitConfig, AGENT_ROOT_DIR, SPOKE_REGISTRY } from "./cli-utils.mjs";
|
|
4
|
+
|
|
5
|
+
const TELEMETRY_FILE = `${AGENT_ROOT_DIR}/telemetry.jsonl`;
|
|
6
|
+
const RAG_RESULTS = new Set(["hit", "weak-hit", "miss", "stale"]);
|
|
7
|
+
const KNOWLEDGE_ACTIONS = new Set(["none", "add_knowledge", "refresh_document"]);
|
|
8
|
+
const TOKEN_QUALITIES = new Set(["useful", "waste", "rework", "saved"]);
|
|
9
|
+
const INTERNAL_SOURCE = "internal";
|
|
10
|
+
const WORKFLOW_EVENT_KIND = "workflow_event";
|
|
11
|
+
|
|
12
|
+
export async function runTelemetry(opts) {
|
|
13
|
+
const argv = process.argv.slice(3); // skip 'telemetry' and 'log/sync/summary'
|
|
14
|
+
const action = process.argv[3];
|
|
15
|
+
|
|
16
|
+
if (action === "log") {
|
|
17
|
+
await logAction(opts);
|
|
18
|
+
} else if (action === "sync") {
|
|
19
|
+
await syncAction(opts);
|
|
20
|
+
} else if (action === "summary") {
|
|
21
|
+
await summaryAction(opts);
|
|
22
|
+
} else if (action === "migrate") {
|
|
23
|
+
await migrateAction(opts);
|
|
24
|
+
} else {
|
|
25
|
+
console.error("Unknown telemetry action: " + action);
|
|
26
|
+
console.log("Usage: npx deuk-agent-rule telemetry <log|sync|summary|migrate> [options]");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function logAction(opts) {
|
|
31
|
+
let resolvedClient = opts.client;
|
|
32
|
+
const lowerModel = (opts.model || "").toLowerCase();
|
|
33
|
+
|
|
34
|
+
if (!resolvedClient) {
|
|
35
|
+
if (lowerModel.includes("codex")) resolvedClient = "Codex";
|
|
36
|
+
else if (lowerModel.includes("copilot")) resolvedClient = "Copilot";
|
|
37
|
+
else if (lowerModel.includes("claude")) resolvedClient = "ClaudeCode";
|
|
38
|
+
else {
|
|
39
|
+
const config = loadInitConfig(opts.cwd);
|
|
40
|
+
const tools = config?.agentTools || [];
|
|
41
|
+
if (tools.includes("codex")) resolvedClient = "Codex";
|
|
42
|
+
else if (tools.includes("copilot")) resolvedClient = "Copilot";
|
|
43
|
+
else if (tools.includes("cursor")) resolvedClient = "Cursor";
|
|
44
|
+
else if (tools.includes("claude")) resolvedClient = "ClaudeCode";
|
|
45
|
+
else {
|
|
46
|
+
for (const spoke of SPOKE_REGISTRY) {
|
|
47
|
+
if (spoke.id !== "antigravity" && spoke.detect(opts.cwd, tools)) {
|
|
48
|
+
if (spoke.id === "copilot") { resolvedClient = "Copilot"; break; }
|
|
49
|
+
if (spoke.id === "codex") { resolvedClient = "Codex"; break; }
|
|
50
|
+
if (spoke.id === "cursor") { resolvedClient = "Cursor"; break; }
|
|
51
|
+
if (spoke.id === "claude") { resolvedClient = "ClaudeCode"; break; }
|
|
52
|
+
if (spoke.id === "windsurf") { resolvedClient = "Windsurf"; break; }
|
|
53
|
+
if (spoke.id === "jetbrains") { resolvedClient = "JetBrains"; break; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!resolvedClient) resolvedClient = "Antigravity";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const entry = appendTelemetryRecord(opts.cwd, {
|
|
62
|
+
source: opts.source || "manual",
|
|
63
|
+
kind: opts.kind || "work",
|
|
64
|
+
event: opts.event || "",
|
|
65
|
+
tokens: Number(opts.tokens || 0),
|
|
66
|
+
tdw: Number(opts.tdw || 0),
|
|
67
|
+
model: opts.model || "UNKNOWN",
|
|
68
|
+
client: resolvedClient,
|
|
69
|
+
ticket: opts.ticket || "",
|
|
70
|
+
action: opts.action || "work",
|
|
71
|
+
file: opts.file || "",
|
|
72
|
+
ragResult: normalizeEnum(opts.ragResult, RAG_RESULTS),
|
|
73
|
+
localFallback: Boolean(opts.localFallback),
|
|
74
|
+
knowledgeAction: normalizeEnum(opts.knowledgeAction, KNOWLEDGE_ACTIONS) || "none",
|
|
75
|
+
tokenQuality: normalizeEnum(opts.tokenQuality, TOKEN_QUALITIES),
|
|
76
|
+
savedTokens: Number(opts.savedTokens || 0),
|
|
77
|
+
occurredAt: opts.occurredAt || ""
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
console.log(`[TELEMETRY] Logged ${entry.tokens} tokens for ticket ${entry.ticket} in ${TELEMETRY_FILE}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function syncAction(opts) {
|
|
84
|
+
const absPath = join(opts.cwd, TELEMETRY_FILE);
|
|
85
|
+
if (!existsSync(absPath)) {
|
|
86
|
+
console.log("[TELEMETRY] No local logs to sync.");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const config = loadInitConfig(opts.cwd);
|
|
91
|
+
const pipelineUrl = opts.remote || config?.pipelineUrl || "http://localhost:8001/api/telemetry/ingest";
|
|
92
|
+
|
|
93
|
+
const lines = readFileSync(absPath, "utf8").split("\n").filter(l => l.trim());
|
|
94
|
+
const entries = lines.map(l => JSON.parse(l));
|
|
95
|
+
const unsynced = entries.filter(e => !e.synced);
|
|
96
|
+
|
|
97
|
+
if (unsynced.length === 0) {
|
|
98
|
+
console.log("[TELEMETRY] All logs already synced.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(`[TELEMETRY] Syncing ${unsynced.length} entries to ${pipelineUrl}...`);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// In a real environment, we'd use fetch or a pipeline sync tool.
|
|
106
|
+
// For now, we simulate the sync success and mark them as synced.
|
|
107
|
+
// We try to use the built-in fetch if available.
|
|
108
|
+
if (typeof fetch === "function") {
|
|
109
|
+
const res = await fetch(pipelineUrl, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "Content-Type": "application/json" },
|
|
112
|
+
body: JSON.stringify({ logs: unsynced })
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
|
115
|
+
} else {
|
|
116
|
+
console.warn("[TELEMETRY] fetch not available, simulating sync...");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const updatedLines = entries.map(e => {
|
|
120
|
+
if (!e.synced) e.synced = true;
|
|
121
|
+
return JSON.stringify(e);
|
|
122
|
+
}).join("\n") + "\n";
|
|
123
|
+
|
|
124
|
+
writeFileSync(absPath, updatedLines, "utf8");
|
|
125
|
+
console.log(`[TELEMETRY] Successfully synced ${unsynced.length} entries.`);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(`[TELEMETRY] Sync failed: ${err.message}`);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function summaryAction(opts) {
|
|
133
|
+
const absPath = join(opts.cwd, TELEMETRY_FILE);
|
|
134
|
+
if (!existsSync(absPath)) {
|
|
135
|
+
console.log("[TELEMETRY] No logs found.");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const lines = readFileSync(absPath, "utf8").split("\n").filter(l => l.trim());
|
|
140
|
+
const logs = lines.map(l => JSON.parse(l));
|
|
141
|
+
const workflowEvents = logs.filter(isInternalWorkflowEvent);
|
|
142
|
+
const workLogs = logs.filter(l => !isInternalWorkflowEvent(l));
|
|
143
|
+
const missingEventCount = logs.filter(l => !String(l.event || "").trim()).length;
|
|
144
|
+
const eventCoverageRate = rate(logs.length - missingEventCount, logs.length);
|
|
145
|
+
|
|
146
|
+
const totalTokens = workLogs.reduce((sum, l) => sum + l.tokens, 0);
|
|
147
|
+
const totalTdwTokens = workLogs.reduce((sum, l) => sum + Number(l.tdw || 0), 0);
|
|
148
|
+
const totalSavedTokens = workLogs.reduce((sum, l) => sum + Number(l.savedTokens || 0), 0);
|
|
149
|
+
const byModel = workLogs.reduce((acc, l) => {
|
|
150
|
+
acc[l.model] = (acc[l.model] || 0) + l.tokens;
|
|
151
|
+
return acc;
|
|
152
|
+
}, {});
|
|
153
|
+
const tdwEntryCount = workLogs.filter(l => Number(l.tdw || 0) > 0).length;
|
|
154
|
+
const tdwAverageTokensPerEntry = tdwEntryCount > 0 ? totalTdwTokens / tdwEntryCount : 0;
|
|
155
|
+
const byRagResult = countBy(workLogs, "ragResult");
|
|
156
|
+
const byTokenQuality = countBy(workLogs, "tokenQuality");
|
|
157
|
+
const byKnowledgeAction = countBy(workLogs, "knowledgeAction");
|
|
158
|
+
const byKnowledgeSourceKind = countBy(workflowEvents, "knowledgeSourceKind");
|
|
159
|
+
const byKnowledgeIngestionCategory = countBy(workflowEvents, "knowledgeIngestionCategory");
|
|
160
|
+
const byKnowledgeCorpus = countBy(workflowEvents, "knowledgeCorpus");
|
|
161
|
+
const byKnowledgeOriginTool = countBy(workflowEvents, "knowledgeOriginTool");
|
|
162
|
+
const localFallbackCount = workLogs.filter(l => l.localFallback).length;
|
|
163
|
+
const ragCalls = Object.values(byRagResult).reduce((sum, n) => sum + n, 0);
|
|
164
|
+
const ragHits = (byRagResult.hit || 0) + (byRagResult["weak-hit"] || 0);
|
|
165
|
+
const ragMisses = byRagResult.miss || 0;
|
|
166
|
+
const staleKnowledge = byRagResult.stale || 0;
|
|
167
|
+
const workflowSummary = summarizeWorkflowEvents(workflowEvents);
|
|
168
|
+
|
|
169
|
+
if (opts.json) {
|
|
170
|
+
console.log(JSON.stringify({
|
|
171
|
+
cwd: opts.cwd,
|
|
172
|
+
totalTokens,
|
|
173
|
+
totalTdwTokens,
|
|
174
|
+
totalSavedTokens,
|
|
175
|
+
logEntries: workLogs.length,
|
|
176
|
+
totalLogEntries: logs.length,
|
|
177
|
+
missingEventCount,
|
|
178
|
+
eventCoverageRate,
|
|
179
|
+
byModel,
|
|
180
|
+
tdwEntryCount,
|
|
181
|
+
tdwCoverageRate: rate(tdwEntryCount, workLogs.length),
|
|
182
|
+
tdwTokenShare: rate(totalTdwTokens, totalTokens),
|
|
183
|
+
tdwAverageTokensPerEntry,
|
|
184
|
+
ragCalls,
|
|
185
|
+
ragHitRate: rate(ragHits, ragCalls),
|
|
186
|
+
ragMissRate: rate(ragMisses, ragCalls),
|
|
187
|
+
staleKnowledgeRate: rate(staleKnowledge, ragCalls),
|
|
188
|
+
localFallbackRate: rate(localFallbackCount, workLogs.length),
|
|
189
|
+
byRagResult,
|
|
190
|
+
byTokenQuality,
|
|
191
|
+
byKnowledgeAction,
|
|
192
|
+
byKnowledgeSourceKind,
|
|
193
|
+
byKnowledgeIngestionCategory,
|
|
194
|
+
byKnowledgeCorpus,
|
|
195
|
+
byKnowledgeOriginTool,
|
|
196
|
+
workflowEvents: workflowSummary
|
|
197
|
+
}, null, 2));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(`\n--- Local Telemetry Summary (${opts.cwd}) ---`);
|
|
202
|
+
console.log(`Total Tokens: ${totalTokens}`);
|
|
203
|
+
console.log(`TDW Tokens: ${totalTdwTokens}`);
|
|
204
|
+
console.log(`Saved Tokens: ${totalSavedTokens}`);
|
|
205
|
+
console.log(`Log Entries: ${workLogs.length}`);
|
|
206
|
+
console.log(`Total Entries: ${logs.length}`);
|
|
207
|
+
console.log(`Event Coverage:`);
|
|
208
|
+
console.log(` - Missing Event Count: ${missingEventCount}`);
|
|
209
|
+
console.log(` - Coverage Rate: ${formatRate(logs.length - missingEventCount, logs.length)}`);
|
|
210
|
+
console.log(`By Model:`);
|
|
211
|
+
Object.entries(byModel).forEach(([m, t]) => {
|
|
212
|
+
console.log(` - ${m}: ${t}`);
|
|
213
|
+
});
|
|
214
|
+
console.log(`TDW:`);
|
|
215
|
+
console.log(` - Entries: ${tdwEntryCount}`);
|
|
216
|
+
console.log(` - Coverage Rate: ${formatRate(tdwEntryCount, workLogs.length)}`);
|
|
217
|
+
console.log(` - Token Share: ${formatRate(totalTdwTokens, totalTokens)}`);
|
|
218
|
+
console.log(` - Average Tokens/Entry: ${tdwAverageTokensPerEntry.toFixed(1)}`);
|
|
219
|
+
console.log(`RAG Quality:`);
|
|
220
|
+
console.log(` - Calls: ${ragCalls}`);
|
|
221
|
+
console.log(` - Hit Rate: ${formatRate(ragHits, ragCalls)}`);
|
|
222
|
+
console.log(` - Miss Rate: ${formatRate(ragMisses, ragCalls)}`);
|
|
223
|
+
console.log(` - Stale Rate: ${formatRate(staleKnowledge, ragCalls)}`);
|
|
224
|
+
console.log(` - Local Fallback Rate: ${formatRate(localFallbackCount, workLogs.length)}`);
|
|
225
|
+
printCounts("By RAG Result", byRagResult);
|
|
226
|
+
printCounts("By Token Quality", byTokenQuality);
|
|
227
|
+
printCounts("By Knowledge Action", byKnowledgeAction);
|
|
228
|
+
printCounts("By Knowledge Source Kind", byKnowledgeSourceKind);
|
|
229
|
+
printCounts("By Knowledge Ingestion Category", byKnowledgeIngestionCategory);
|
|
230
|
+
printCounts("By Knowledge Corpus", byKnowledgeCorpus);
|
|
231
|
+
printCounts("By Knowledge Origin Tool", byKnowledgeOriginTool);
|
|
232
|
+
console.log(`Internal Workflow Events:`);
|
|
233
|
+
console.log(` - Events: ${workflowSummary.eventCount}`);
|
|
234
|
+
console.log(` - Tickets: ${workflowSummary.ticketCount}`);
|
|
235
|
+
console.log(` - Average Time To Phase Move: ${formatDuration(workflowSummary.averageTimeToPhaseMoveMs)}`);
|
|
236
|
+
console.log(` - Average Time To Close: ${formatDuration(workflowSummary.averageTimeToCloseMs)}`);
|
|
237
|
+
console.log(` - Average Time To Archive: ${formatDuration(workflowSummary.averageTimeToArchiveMs)}`);
|
|
238
|
+
printCounts("By Workflow Event", workflowSummary.byEvent);
|
|
239
|
+
console.log("-------------------------------------------\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function migrateAction(opts) {
|
|
243
|
+
const absPath = join(opts.cwd, TELEMETRY_FILE);
|
|
244
|
+
if (!existsSync(absPath)) {
|
|
245
|
+
console.log("[TELEMETRY] No logs found.");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const lines = readFileSync(absPath, "utf8").split("\n").filter(l => l.trim());
|
|
250
|
+
const entries = lines.map(l => JSON.parse(l));
|
|
251
|
+
const migrated = entries.map(entry => normalizeTelemetryRecord(entry));
|
|
252
|
+
const changedCount = migrated.filter((entry, index) => JSON.stringify(entry) !== JSON.stringify(entries[index])).length;
|
|
253
|
+
|
|
254
|
+
if (changedCount === 0) {
|
|
255
|
+
console.log("[TELEMETRY] Telemetry logs already normalized.");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
writeFileSync(absPath, migrated.map(entry => JSON.stringify(entry)).join("\n") + "\n", "utf8");
|
|
260
|
+
console.log(`[TELEMETRY] Migrated ${changedCount} telemetry entries.`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function appendTelemetryRecord(cwd, entry = {}) {
|
|
264
|
+
const telemetryDir = join(cwd, AGENT_ROOT_DIR);
|
|
265
|
+
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
266
|
+
const telemetryPath = join(cwd, TELEMETRY_FILE);
|
|
267
|
+
const occurredAt = entry.occurredAt || new Date().toISOString();
|
|
268
|
+
const event = resolveTelemetryEvent(entry);
|
|
269
|
+
const payload = {
|
|
270
|
+
ts: Math.floor(new Date(occurredAt).getTime() / 1000) || Math.floor(Date.now() / 1000),
|
|
271
|
+
occurredAt,
|
|
272
|
+
source: entry.source || "manual",
|
|
273
|
+
kind: entry.kind || "work",
|
|
274
|
+
event,
|
|
275
|
+
tokens: Number(entry.tokens || 0),
|
|
276
|
+
tdw: Number(entry.tdw || 0),
|
|
277
|
+
model: entry.model || "UNKNOWN",
|
|
278
|
+
client: entry.client || "",
|
|
279
|
+
ticket: entry.ticket || "",
|
|
280
|
+
action: entry.action || "work",
|
|
281
|
+
file: entry.file || "",
|
|
282
|
+
phase: entry.phase ?? "",
|
|
283
|
+
status: entry.status || "",
|
|
284
|
+
ragResult: normalizeEnum(entry.ragResult, RAG_RESULTS),
|
|
285
|
+
localFallback: Boolean(entry.localFallback),
|
|
286
|
+
knowledgeAction: normalizeEnum(entry.knowledgeAction, KNOWLEDGE_ACTIONS) || "",
|
|
287
|
+
knowledgeSourceKind: normalizeText(entry.knowledgeSourceKind),
|
|
288
|
+
knowledgeIngestionCategory: normalizeText(entry.knowledgeIngestionCategory),
|
|
289
|
+
knowledgeCorpus: normalizeText(entry.knowledgeCorpus),
|
|
290
|
+
knowledgeOriginTool: normalizeText(entry.knowledgeOriginTool),
|
|
291
|
+
knowledgeFreshness: normalizeText(entry.knowledgeFreshness),
|
|
292
|
+
tokenQuality: normalizeEnum(entry.tokenQuality, TOKEN_QUALITIES),
|
|
293
|
+
savedTokens: Number(entry.savedTokens || 0),
|
|
294
|
+
synced: false
|
|
295
|
+
};
|
|
296
|
+
appendFileSync(telemetryPath, JSON.stringify(payload) + "\n", "utf8");
|
|
297
|
+
return payload;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function appendInternalWorkflowEvent(cwd, event = {}) {
|
|
301
|
+
return appendTelemetryRecord(cwd, {
|
|
302
|
+
...event,
|
|
303
|
+
source: INTERNAL_SOURCE,
|
|
304
|
+
kind: WORKFLOW_EVENT_KIND,
|
|
305
|
+
model: event.model || "workflow",
|
|
306
|
+
client: event.client || "DeukAgentRules",
|
|
307
|
+
tokens: 0,
|
|
308
|
+
tdw: 0,
|
|
309
|
+
action: event.action || event.event || "workflow-event"
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function resolveTelemetryEvent(entry = {}) {
|
|
314
|
+
const explicitEvent = normalizeText(entry.event);
|
|
315
|
+
if (explicitEvent) return explicitEvent;
|
|
316
|
+
|
|
317
|
+
if (isInternalWorkflowEvent({
|
|
318
|
+
source: entry.source || INTERNAL_SOURCE,
|
|
319
|
+
kind: entry.kind || WORKFLOW_EVENT_KIND
|
|
320
|
+
})) {
|
|
321
|
+
return normalizeText(entry.action) || normalizeText(entry.kind) || "workflow-event";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return normalizeText(entry.kind) || "work";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function normalizeTelemetryRecord(entry = {}) {
|
|
328
|
+
const cloned = { ...entry };
|
|
329
|
+
cloned.source = cloned.source || "manual";
|
|
330
|
+
cloned.kind = cloned.kind || "work";
|
|
331
|
+
cloned.action = cloned.action || "work";
|
|
332
|
+
cloned.event = resolveTelemetryEvent(cloned);
|
|
333
|
+
cloned.synced = normalizeSyncedState(cloned.synced);
|
|
334
|
+
return cloned;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function normalizeEnum(value, allowed) {
|
|
338
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
339
|
+
return allowed.has(normalized) ? normalized : "";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizeText(value) {
|
|
343
|
+
return String(value || "").trim();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function normalizeSyncedState(value) {
|
|
347
|
+
return value === true || value === "true";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function countBy(logs, key) {
|
|
351
|
+
return logs.reduce((acc, log) => {
|
|
352
|
+
const value = log[key];
|
|
353
|
+
if (value) acc[value] = (acc[value] || 0) + 1;
|
|
354
|
+
return acc;
|
|
355
|
+
}, {});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isInternalWorkflowEvent(log) {
|
|
359
|
+
return log?.source === INTERNAL_SOURCE && log?.kind === WORKFLOW_EVENT_KIND;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function summarizeWorkflowEvents(events) {
|
|
363
|
+
const sorted = [...events].sort((a, b) => eventTime(a) - eventTime(b));
|
|
364
|
+
const byTicket = new Map();
|
|
365
|
+
for (const event of sorted) {
|
|
366
|
+
const ticket = event.ticket || "";
|
|
367
|
+
if (!ticket) continue;
|
|
368
|
+
const rows = byTicket.get(ticket) || [];
|
|
369
|
+
rows.push(event);
|
|
370
|
+
byTicket.set(ticket, rows);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const closeDurations = [];
|
|
374
|
+
const archiveDurations = [];
|
|
375
|
+
const phaseMoveDurations = [];
|
|
376
|
+
for (const rows of byTicket.values()) {
|
|
377
|
+
const created = rows.find(e => e.event === "ticket_created");
|
|
378
|
+
const closed = rows.find(e => e.event === "ticket_closed");
|
|
379
|
+
const archived = rows.find(e => e.event === "ticket_archived");
|
|
380
|
+
const firstPhaseMove = rows.find(e => e.event === "ticket_phase_moved");
|
|
381
|
+
if (created && closed) closeDurations.push(eventTime(closed) - eventTime(created));
|
|
382
|
+
if (created && archived) archiveDurations.push(eventTime(archived) - eventTime(created));
|
|
383
|
+
if (created && firstPhaseMove) phaseMoveDurations.push(eventTime(firstPhaseMove) - eventTime(created));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
eventCount: events.length,
|
|
388
|
+
ticketCount: byTicket.size,
|
|
389
|
+
byEvent: countBy(events, "event"),
|
|
390
|
+
averageTimeToCloseMs: average(closeDurations),
|
|
391
|
+
averageTimeToArchiveMs: average(archiveDurations),
|
|
392
|
+
averageTimeToPhaseMoveMs: average(phaseMoveDurations)
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function eventTime(event) {
|
|
397
|
+
const fromOccurredAt = Date.parse(event?.occurredAt || "");
|
|
398
|
+
if (Number.isFinite(fromOccurredAt)) return fromOccurredAt;
|
|
399
|
+
return Number(event?.ts || 0) * 1000;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function average(values) {
|
|
403
|
+
return values.length > 0 ? values.reduce((sum, n) => sum + n, 0) / values.length : 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function formatDuration(ms) {
|
|
407
|
+
if (!ms) return "0ms";
|
|
408
|
+
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|
409
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
410
|
+
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
|
|
411
|
+
return `${(ms / 3600000).toFixed(1)}h`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function rate(value, total) {
|
|
415
|
+
return total > 0 ? value / total : 0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function formatRate(value, total) {
|
|
419
|
+
return `${(rate(value, total) * 100).toFixed(1)}%`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function printCounts(label, counts) {
|
|
423
|
+
const entries = Object.entries(counts);
|
|
424
|
+
if (entries.length === 0) return;
|
|
425
|
+
console.log(`${label}:`);
|
|
426
|
+
entries.forEach(([name, count]) => {
|
|
427
|
+
console.log(` - ${name}: ${count}`);
|
|
428
|
+
});
|
|
429
|
+
}
|