@elizaos/plugin-gitpathologist 2.0.3-beta.2 → 2.0.3-beta.4

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,1023 @@
1
+ // src/index.ts
2
+ import { logger as logger3 } from "@elizaos/core";
3
+
4
+ // src/actions/git-pathology.ts
5
+ import path3 from "node:path";
6
+
7
+ // src/render.ts
8
+ function short(sha) {
9
+ return sha.slice(0, 7);
10
+ }
11
+ function inflectionLine(point) {
12
+ const direction = point.delta >= 0 ? "+" : "";
13
+ return `- \`${short(point.sha)}\` (${point.date.slice(0, 10)}, ${point.author}): score ${point.score.toFixed(2)} ${direction}${point.delta.toFixed(2)} — ${point.reasonShort}`;
14
+ }
15
+ function rotCauseBlock(cause) {
16
+ const [from, to] = cause.shaRange;
17
+ const evidence = cause.evidence.length > 0 ? `
18
+ - Evidence: ${cause.evidence.map(short).join(", ")}` : "";
19
+ return `### ${cause.category} — \`${short(from)}\`..\`${short(to)}\`
20
+
21
+ ${cause.narrative}${evidence}`;
22
+ }
23
+ function renderReport(report) {
24
+ const window = `${report.window.since.slice(0, 10)} → ${report.window.until.slice(0, 10)}`;
25
+ const peaks = report.peaks.length === 0 ? "_None detected in window._" : report.peaks.map(inflectionLine).join(`
26
+ `);
27
+ const drifts = report.drifts.length === 0 ? "_None detected in window._" : report.drifts.map(inflectionLine).join(`
28
+ `);
29
+ const causes = report.rotCauses.length === 0 ? "_No drift narration generated. Either no drifts detected, or budget = 0._" : report.rotCauses.map(rotCauseBlock).join(`
30
+
31
+ `);
32
+ const authors = report.authors.length > 0 ? report.authors.join(", ") : "_none_";
33
+ return `# Git Pathology — \`${report.surface}\`
34
+
35
+ **Repo:** \`${report.repoRoot}\`
36
+ **Window:** ${window}
37
+ **HEAD:** \`${short(report.headSha)}\`
38
+ **Commits analyzed:** ${report.commitCount} (${authors})
39
+ **LLM calls:** ${report.llmCalls}
40
+
41
+ ## Peaks (local maxima of health)
42
+
43
+ ${peaks}
44
+
45
+ ## Drift inflections (sustained downturns)
46
+
47
+ ${drifts}
48
+
49
+ ## Rot post-mortem
50
+
51
+ ${causes}
52
+ `;
53
+ }
54
+
55
+ // src/services/git-pathology-service.ts
56
+ import { logger as logger2, Service } from "@elizaos/core";
57
+
58
+ // src/cache/report-cache.ts
59
+ import { createHash } from "node:crypto";
60
+ import {
61
+ existsSync,
62
+ mkdirSync,
63
+ readdirSync,
64
+ readFileSync,
65
+ renameSync,
66
+ statSync,
67
+ writeFileSync
68
+ } from "node:fs";
69
+ import path from "node:path";
70
+ function makeCacheKey(input) {
71
+ return createHash("sha256").update(`${input.surface}\x00${input.since}`).digest("hex");
72
+ }
73
+ function defaultCacheDir(repoRoot) {
74
+ const override = process.env.GITPATHOLOGIST_CACHE_DIR?.trim();
75
+ if (override) {
76
+ return path.isAbsolute(override) ? override : path.join(repoRoot, override);
77
+ }
78
+ return path.join(repoRoot, ".eliza", "gitpathology");
79
+ }
80
+ function ensureCacheDir(cacheDir) {
81
+ if (!existsSync(cacheDir))
82
+ mkdirSync(cacheDir, { recursive: true });
83
+ }
84
+ function createReportCache(cacheDir) {
85
+ ensureCacheDir(cacheDir);
86
+ const pathFor = (key) => path.join(cacheDir, `${key}.json`);
87
+ return {
88
+ dir: cacheDir,
89
+ read(key) {
90
+ const file = pathFor(key);
91
+ if (!existsSync(file))
92
+ return null;
93
+ try {
94
+ const raw = readFileSync(file, "utf8");
95
+ return JSON.parse(raw);
96
+ } catch {
97
+ return null;
98
+ }
99
+ },
100
+ write(report) {
101
+ const file = pathFor(report.cacheKey);
102
+ const tmp = path.join(cacheDir, `.${report.cacheKey}.${process.pid}.${Date.now()}.tmp`);
103
+ writeFileSync(tmp, JSON.stringify(report, null, 2), "utf8");
104
+ renameSync(tmp, file);
105
+ },
106
+ list() {
107
+ if (!existsSync(cacheDir))
108
+ return [];
109
+ const files = readdirSync(cacheDir).filter((f) => f.endsWith(".json"));
110
+ const out = [];
111
+ for (const file of files) {
112
+ try {
113
+ const full = path.join(cacheDir, file);
114
+ const stat = statSync(full);
115
+ const raw = readFileSync(full, "utf8");
116
+ const report = JSON.parse(raw);
117
+ out.push({
118
+ cacheKey: report.cacheKey,
119
+ surface: report.surface,
120
+ generatedAt: report.generatedAt,
121
+ headSha: report.headSha,
122
+ commitCount: report.commitCount,
123
+ sizeBytes: stat.size
124
+ });
125
+ } catch {}
126
+ }
127
+ return out.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
128
+ },
129
+ isFreshFor(key, currentHeadSha) {
130
+ const cached = this.read(key);
131
+ return cached?.headSha === currentHeadSha;
132
+ }
133
+ };
134
+ }
135
+
136
+ // src/pipeline/classify.ts
137
+ var PREFIX_MAP = {
138
+ feat: "feature",
139
+ feature: "feature",
140
+ fix: "fix",
141
+ bug: "fix",
142
+ hotfix: "fix",
143
+ refactor: "refactor",
144
+ perf: "refactor",
145
+ revert: "revert",
146
+ chore: "chore",
147
+ docs: "chore",
148
+ doc: "chore",
149
+ style: "chore",
150
+ test: "chore",
151
+ ci: "chore",
152
+ build: "chore"
153
+ };
154
+ var CONVENTIONAL_RE = /^([a-z]+)(?:\(([^)]+)\))?!?:\s*(.+)$/i;
155
+ var WIP_RE = /^(?:wip|fixup!|squash!|amend!)\b/i;
156
+ var REVERT_SUBJECT_RE = /^Revert\b/;
157
+ var MERGE_SUBJECT_RE = /^Merge\b/;
158
+ function classifyOne(commit) {
159
+ const subject = commit.subject.trim();
160
+ const riskFlags = [];
161
+ let type = "other";
162
+ let scope;
163
+ if (commit.parents.length > 1) {
164
+ type = "merge";
165
+ } else if (MERGE_SUBJECT_RE.test(subject)) {
166
+ type = "merge";
167
+ } else if (REVERT_SUBJECT_RE.test(subject)) {
168
+ type = "revert";
169
+ riskFlags.push("revert-subject");
170
+ } else if (WIP_RE.test(subject)) {
171
+ type = "wip";
172
+ riskFlags.push("wip-message");
173
+ } else {
174
+ const match = subject.match(CONVENTIONAL_RE);
175
+ if (match) {
176
+ const prefix = (match[1] ?? "").toLowerCase();
177
+ const mapped = PREFIX_MAP[prefix];
178
+ if (mapped) {
179
+ type = mapped;
180
+ scope = match[2];
181
+ }
182
+ }
183
+ }
184
+ const churn = commit.files.reduce((acc, f) => acc + f.added + f.deleted, 0);
185
+ if (churn >= 500)
186
+ riskFlags.push("large-churn");
187
+ if (commit.files.length >= 20)
188
+ riskFlags.push("wide-blast");
189
+ if (subject.length < 12 && type === "other")
190
+ riskFlags.push("terse-message");
191
+ if (subject.includes("!:"))
192
+ riskFlags.push("breaking");
193
+ return {
194
+ ...commit,
195
+ type,
196
+ scope,
197
+ riskFlags,
198
+ classifiedBy: "rule"
199
+ };
200
+ }
201
+ function classify(commits) {
202
+ return commits.map(classifyOne);
203
+ }
204
+
205
+ // src/pipeline/inflect.ts
206
+ var PEAK_WINDOW = 2;
207
+ var PEAK_MIN_SCORE = 0.05;
208
+ var PEAK_LIMIT = 5;
209
+ var DRIFT_WINDOW = 5;
210
+ var DRIFT_DROP = 0.25;
211
+ var DRIFT_LIMIT = 5;
212
+ function toInflection(point, reason) {
213
+ return {
214
+ sha: point.sha,
215
+ date: point.date,
216
+ author: point.author,
217
+ score: point.score,
218
+ delta: point.delta,
219
+ reasonShort: reason
220
+ };
221
+ }
222
+ function reasonForPeak(point) {
223
+ const parts = [];
224
+ if (point.type === "feature")
225
+ parts.push("feature landed");
226
+ else if (point.type === "refactor")
227
+ parts.push("clean refactor");
228
+ else if (point.type === "fix")
229
+ parts.push("targeted fix");
230
+ else
231
+ parts.push(`${point.type} commit`);
232
+ if (point.riskFlags.includes("large-churn") === false && point.churn < 200) {
233
+ parts.push("low churn");
234
+ }
235
+ if (point.delta > 0.4)
236
+ parts.push("strong delta");
237
+ return parts.join(", ") || "local maximum";
238
+ }
239
+ function reasonForDrift(point, avgAfter) {
240
+ const drop = (point.score - avgAfter).toFixed(2);
241
+ const flags = point.riskFlags.length > 0 ? ` flags=${point.riskFlags.join("|")}` : "";
242
+ return `score drops ${drop} over next ${DRIFT_WINDOW} commits${flags}`;
243
+ }
244
+ function avg(points, from, count) {
245
+ let sum = 0;
246
+ let taken = 0;
247
+ for (let i = from;i < points.length && taken < count; i++) {
248
+ const p = points[i];
249
+ if (!p)
250
+ continue;
251
+ sum += p.score;
252
+ taken += 1;
253
+ }
254
+ return taken === 0 ? 0 : sum / taken;
255
+ }
256
+ function findInflections(points) {
257
+ const peaks = [];
258
+ const drifts = [];
259
+ for (let i = 0;i < points.length; i++) {
260
+ const point = points[i];
261
+ if (!point)
262
+ continue;
263
+ if (point.score < PEAK_MIN_SCORE)
264
+ continue;
265
+ const window = points.slice(Math.max(0, i - PEAK_WINDOW), i + PEAK_WINDOW + 1);
266
+ const isMaxInWindow = window.every((p) => p.score <= point.score);
267
+ const hasStrictlyLessNeighbour = window.some((p) => p.score < point.score);
268
+ if (!isMaxInWindow || !hasStrictlyLessNeighbour)
269
+ continue;
270
+ const tiedEarlier = peaks.some((existing) => {
271
+ const prevIdx = points.findIndex((p) => p.sha === existing.sha);
272
+ return prevIdx >= 0 && prevIdx >= i - PEAK_WINDOW && existing.score === point.score;
273
+ });
274
+ if (tiedEarlier)
275
+ continue;
276
+ peaks.push(toInflection(point, reasonForPeak(point)));
277
+ }
278
+ for (let i = 0;i < points.length; i++) {
279
+ const point = points[i];
280
+ if (!point)
281
+ continue;
282
+ if (i + DRIFT_WINDOW >= points.length)
283
+ break;
284
+ const after = avg(points, i + 1, DRIFT_WINDOW);
285
+ const drop = point.score - after;
286
+ if (drop >= DRIFT_DROP) {
287
+ drifts.push(toInflection(point, reasonForDrift(point, after)));
288
+ }
289
+ }
290
+ peaks.sort((a, b) => b.score - a.score);
291
+ drifts.sort((a, b) => b.score - b.delta - (a.score - a.delta));
292
+ return {
293
+ peaks: peaks.slice(0, PEAK_LIMIT),
294
+ drifts: drifts.slice(0, DRIFT_LIMIT)
295
+ };
296
+ }
297
+
298
+ // src/pipeline/narrate.ts
299
+ import { logger, ModelType } from "@elizaos/core";
300
+
301
+ // src/secret-scrubber.ts
302
+ var SECRET_PATTERNS = [
303
+ { label: "ANTHROPIC", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
304
+ { label: "OPENAI", pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
305
+ { label: "GITHUB", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
306
+ { label: "AWS", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
307
+ { label: "SLACK", pattern: /\bxox[bpoa]-[A-Za-z0-9-]{10,}\b/g },
308
+ { label: "BEARER", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/g }
309
+ ];
310
+ var SECRET_ENV_NAME = /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|API|AUTH|CREDENTIAL)[A-Z0-9_]*)\s*[:=]\s*['"]?([A-Za-z0-9_\-.+/=]{12,})['"]?/g;
311
+ function scrubSecrets(input) {
312
+ if (!input)
313
+ return input;
314
+ let out = input;
315
+ for (const { label, pattern } of SECRET_PATTERNS) {
316
+ out = out.replace(pattern, `<REDACTED:${label}>`);
317
+ }
318
+ out = out.replace(SECRET_ENV_NAME, (_match, name) => `${name}=<REDACTED:ENV>`);
319
+ return out;
320
+ }
321
+ function scrubSecretsDeep(value) {
322
+ if (typeof value === "string")
323
+ return scrubSecrets(value);
324
+ if (Array.isArray(value))
325
+ return value.map(scrubSecretsDeep);
326
+ if (value && typeof value === "object") {
327
+ const out = {};
328
+ for (const [k, v] of Object.entries(value)) {
329
+ out[k] = scrubSecretsDeep(v);
330
+ }
331
+ return out;
332
+ }
333
+ return value;
334
+ }
335
+
336
+ // src/pipeline/scan.ts
337
+ import { spawnSync } from "node:child_process";
338
+ import path2 from "node:path";
339
+ var RECORD_SEP = "\x1E";
340
+ var UNIT_SEP = "\x1F";
341
+ var LOG_FORMAT = `${[`${RECORD_SEP}COMMIT`, "%H", "%P", "%an", "%ae", "%aI", "%s"].join(UNIT_SEP)}${UNIT_SEP}%b`;
342
+ function resolveSurfacePath(surface) {
343
+ if (path2.isAbsolute(surface.path)) {
344
+ return path2.relative(surface.repoRoot, surface.path) || ".";
345
+ }
346
+ return surface.path;
347
+ }
348
+ function runGit(repoRoot, args) {
349
+ const result = spawnSync("git", args, {
350
+ cwd: repoRoot,
351
+ encoding: "utf8",
352
+ maxBuffer: 64 * 1024 * 1024
353
+ });
354
+ if (result.error || result.status !== 0) {
355
+ const detail = result.error?.message ?? result.stderr?.toString() ?? "";
356
+ throw new Error(`git ${args.join(" ")} failed: ${detail}`);
357
+ }
358
+ return result.stdout.toString();
359
+ }
360
+ function headSha(repoRoot) {
361
+ return runGit(repoRoot, ["rev-parse", "HEAD"]).trim();
362
+ }
363
+ function inferStatus(added, deleted) {
364
+ if (added > 0 && deleted === 0)
365
+ return "A";
366
+ if (added === 0 && deleted > 0)
367
+ return "D";
368
+ return "M";
369
+ }
370
+ function parseFileBlock(lines) {
371
+ const touches = [];
372
+ for (const line of lines) {
373
+ if (!line)
374
+ continue;
375
+ const parts = line.split("\t");
376
+ if (parts.length < 3)
377
+ continue;
378
+ const [addedRaw, deletedRaw, ...pathParts] = parts;
379
+ const addedStr = addedRaw ?? "0";
380
+ const deletedStr = deletedRaw ?? "0";
381
+ const added = addedStr === "-" ? 0 : Number.parseInt(addedStr, 10);
382
+ const deleted = deletedStr === "-" ? 0 : Number.parseInt(deletedStr, 10);
383
+ if (!Number.isFinite(added) || !Number.isFinite(deleted))
384
+ continue;
385
+ const filePath = pathParts.join("\t");
386
+ if (!filePath)
387
+ continue;
388
+ touches.push({ path: filePath, added, deleted, status: inferStatus(added, deleted) });
389
+ }
390
+ return touches;
391
+ }
392
+ function scan(surface, options) {
393
+ const surfacePath = resolveSurfacePath(surface);
394
+ const args = [
395
+ "log",
396
+ "--no-color",
397
+ `--since=${normalizeSince(options.since)}`,
398
+ `--pretty=format:${LOG_FORMAT}`,
399
+ "--numstat",
400
+ "--no-renames",
401
+ "--",
402
+ surfacePath
403
+ ];
404
+ const raw = runGit(surface.repoRoot, args);
405
+ if (!raw.trim())
406
+ return [];
407
+ const records = raw.split(RECORD_SEP).filter((r) => r.trim());
408
+ const commits = [];
409
+ for (const record of records) {
410
+ if (!record.startsWith("COMMIT"))
411
+ continue;
412
+ const rest = record.slice("COMMIT".length);
413
+ const fields = rest.split(UNIT_SEP);
414
+ if (fields.length < 8)
415
+ continue;
416
+ const [, sha, parentsStr, author, authorEmail, date, subject, bodyAndFiles] = fields;
417
+ if (!sha || !date)
418
+ continue;
419
+ const lines = (bodyAndFiles ?? "").split(`
420
+ `);
421
+ const splitIdx = findFileBlockStart(lines);
422
+ const body = lines.slice(0, splitIdx).join(`
423
+ `).trim();
424
+ const fileLines = lines.slice(splitIdx).filter((l) => l.length > 0);
425
+ const files = parseFileBlock(fileLines);
426
+ commits.push({
427
+ sha,
428
+ parents: parentsStr ? parentsStr.split(" ").filter(Boolean) : [],
429
+ author: author ?? "",
430
+ authorEmail: authorEmail ?? "",
431
+ date,
432
+ subject: subject ?? "",
433
+ body,
434
+ files,
435
+ diffSnippet: ""
436
+ });
437
+ }
438
+ return commits;
439
+ }
440
+ function findFileBlockStart(lines) {
441
+ for (let i = 0;i < lines.length; i++) {
442
+ const line = lines[i] ?? "";
443
+ if (!line)
444
+ continue;
445
+ const first = line.split("\t")[0] ?? "";
446
+ if (/^\d+$/.test(first) || first === "-")
447
+ return i;
448
+ }
449
+ return lines.length;
450
+ }
451
+ var RELATIVE_SINCE = /^(\d+)\s*(d|w|m|y)$/;
452
+ function normalizeSince(since) {
453
+ const match = since.trim().match(RELATIVE_SINCE);
454
+ if (!match)
455
+ return since;
456
+ const n = match[1] ?? "0";
457
+ const unit = match[2] ?? "d";
458
+ const map = {
459
+ d: "days",
460
+ w: "weeks",
461
+ m: "months",
462
+ y: "years"
463
+ };
464
+ return `${n} ${map[unit] ?? "days"} ago`;
465
+ }
466
+ function fetchDiffSnippet(repoRoot, sha, surfacePath, maxBytes = 16 * 1024) {
467
+ const args = ["show", "--no-color", "-U2", "--format=", sha, "--", surfacePath];
468
+ const result = spawnSync("git", args, {
469
+ cwd: repoRoot,
470
+ encoding: "utf8",
471
+ maxBuffer: 8 * 1024 * 1024
472
+ });
473
+ if (result.status !== 0)
474
+ return "";
475
+ const out = result.stdout.toString();
476
+ return out.length > maxBytes ? `${out.slice(0, maxBytes)}
477
+ ... [truncated]` : out;
478
+ }
479
+
480
+ // src/pipeline/narrate.ts
481
+ var LOG_PREFIX = "[GitPathology/narrate]";
482
+ var VALID_CATEGORIES = new Set([
483
+ "rushed-fix",
484
+ "scope-creep",
485
+ "bad-merge",
486
+ "revert-cycle",
487
+ "churn-spiral",
488
+ "other"
489
+ ]);
490
+ async function narrate(runtime, ctx) {
491
+ const rotCauses = [];
492
+ let llmCalls = 0;
493
+ const indexBySha = new Map(ctx.timeline.map((point, idx) => [point.sha, idx]));
494
+ const useModelFn = runtime?.useModel;
495
+ const budget = Math.max(0, Math.floor(ctx.budget));
496
+ for (const drift of ctx.drifts) {
497
+ const idx = indexBySha.get(drift.sha);
498
+ if (idx === undefined)
499
+ continue;
500
+ const point = ctx.timeline[idx];
501
+ if (!point)
502
+ continue;
503
+ const before = ctx.timeline.slice(Math.max(0, idx - 3), idx);
504
+ const after = ctx.timeline.slice(idx + 1, idx + 4);
505
+ const fallback = deterministicRotCause(point, before, after, drift);
506
+ if (typeof useModelFn === "function" && llmCalls < budget) {
507
+ const diff = scrubSecrets(fetchDiffSnippet(ctx.repoRoot, point.sha, ctx.surfacePath, 8 * 1024));
508
+ try {
509
+ const result = await callModel(useModelFn, buildPrompt(ctx.surfacePath, point, before, after, diff));
510
+ llmCalls += 1;
511
+ const parsed = parseRotCause(result);
512
+ if (parsed) {
513
+ rotCauses.push({
514
+ ...fallback,
515
+ category: parsed.category,
516
+ narrative: parsed.narrative
517
+ });
518
+ continue;
519
+ }
520
+ logger.warn(`${LOG_PREFIX} model returned unparseable rot cause for ${point.sha}`);
521
+ } catch (err) {
522
+ logger.warn(`${LOG_PREFIX} model call failed for ${point.sha}: ${err.message}`);
523
+ }
524
+ } else if (typeof useModelFn !== "function" && llmCalls === 0 && rotCauses.length === 0) {
525
+ logger.warn(`${LOG_PREFIX} runtime has no useModel; using deterministic rot-cause fallback`);
526
+ }
527
+ rotCauses.push(fallback);
528
+ }
529
+ return { rotCauses, llmCalls };
530
+ }
531
+ function deterministicRotCause(point, before, after, drift) {
532
+ const category = categorizeDeterministically(point, before, after);
533
+ const flags = point.riskFlags.length > 0 ? point.riskFlags.join(", ") : "no explicit flags";
534
+ const previousScore = before.at(-1)?.score;
535
+ const nextScore = after.at(-1)?.score;
536
+ const narrative = [
537
+ `${point.sha.slice(0, 7)} marks a ${Math.abs(drift.delta).toFixed(2)}-point quality drop on this surface, with ${point.churn} churn across ${point.files.length} file(s) and ${flags}.`,
538
+ `The surrounding window moves from ${typeof previousScore === "number" ? previousScore.toFixed(2) : "no prior score"} to ${point.score.toFixed(2)}${typeof nextScore === "number" ? ` and then ${nextScore.toFixed(2)}` : ""}, so this commit is a deterministic inflection even without LLM narration.`
539
+ ].join(" ");
540
+ return {
541
+ shaRange: rangeFor(point, after),
542
+ category,
543
+ evidence: evidenceShas(point, before, after),
544
+ narrative
545
+ };
546
+ }
547
+ function categorizeDeterministically(point, before, after) {
548
+ const subject = point.subject.toLowerCase();
549
+ const flags = new Set(point.riskFlags.map((flag) => flag.toLowerCase()));
550
+ if (point.type === "revert" || subject.includes("revert"))
551
+ return "revert-cycle";
552
+ if (point.type === "merge" || flags.has("merge"))
553
+ return "bad-merge";
554
+ if (flags.has("hotfix") || subject.includes("hotfix") || subject.includes("quick fix")) {
555
+ return "rushed-fix";
556
+ }
557
+ const neighboringChurn = [...before, ...after].reduce((sum, commit) => sum + commit.churn, 0);
558
+ if (point.churn > 500 || neighboringChurn > 1000)
559
+ return "churn-spiral";
560
+ if (point.files.length > 8 || flags.has("large-change"))
561
+ return "scope-creep";
562
+ return "other";
563
+ }
564
+ function rangeFor(point, after) {
565
+ const last = after.length > 0 ? after[after.length - 1] : null;
566
+ return [point.sha, last ? last.sha : point.sha];
567
+ }
568
+ function evidenceShas(point, before, after) {
569
+ return [...before.map((p) => p.sha), point.sha, ...after.map((p) => p.sha)];
570
+ }
571
+ function buildPrompt(surface, point, before, after, diff) {
572
+ const fmt = (p) => ` ${p.sha.slice(0, 7)} [${p.type}] (${p.churn} churn, score ${p.score.toFixed(2)}) ${scrubSecrets(p.subject)}`;
573
+ return [
574
+ "You are diagnosing the start of a code-quality decline in a git repository surface.",
575
+ "",
576
+ `Surface: ${surface}`,
577
+ `Drift commit: ${point.sha.slice(0, 7)} by ${point.author} on ${point.date.slice(0, 10)}`,
578
+ ` Subject: ${scrubSecrets(point.subject)}`,
579
+ ` Type: ${point.type} Risk flags: ${point.riskFlags.join(", ") || "(none)"} Churn: ${point.churn} Files: ${point.files.length}`,
580
+ ` Score: ${point.score.toFixed(2)} Delta: ${point.delta.toFixed(2)}`,
581
+ "",
582
+ "Commits immediately before (oldest first):",
583
+ before.length === 0 ? " (none in window)" : before.map(fmt).join(`
584
+ `),
585
+ "",
586
+ "Commits immediately after (oldest first):",
587
+ after.length === 0 ? " (none in window)" : after.map(fmt).join(`
588
+ `),
589
+ "",
590
+ "Diff snippet of the drift commit (secrets redacted):",
591
+ diff || " (no diff available)",
592
+ "",
593
+ "Classify the most likely cause from this set:",
594
+ ' "rushed-fix", "scope-creep", "bad-merge", "revert-cycle", "churn-spiral", "other"',
595
+ "",
596
+ "Then write a 2-3 sentence narrative explaining WHY this commit looks like the start of decline. Reference specific evidence from the commits or diff above.",
597
+ "",
598
+ 'Respond with exactly one JSON object: {"category": "<one of the above>", "narrative": "<2-3 sentences>"}'
599
+ ].join(`
600
+ `);
601
+ }
602
+ async function callModel(useModelFn, prompt) {
603
+ const result = await useModelFn(ModelType.TEXT_SMALL, {
604
+ prompt,
605
+ temperature: 0.2,
606
+ stream: false
607
+ });
608
+ if (typeof result !== "string")
609
+ return "";
610
+ return result;
611
+ }
612
+ function parseRotCause(raw) {
613
+ if (!raw)
614
+ return null;
615
+ const jsonStart = raw.indexOf("{");
616
+ const jsonEnd = raw.lastIndexOf("}");
617
+ if (jsonStart < 0 || jsonEnd <= jsonStart)
618
+ return null;
619
+ const slice = raw.slice(jsonStart, jsonEnd + 1);
620
+ try {
621
+ const obj = JSON.parse(slice);
622
+ const category = typeof obj.category === "string" ? obj.category : "other";
623
+ const narrative = typeof obj.narrative === "string" ? obj.narrative.trim() : "";
624
+ if (!narrative)
625
+ return null;
626
+ const safeCategory = VALID_CATEGORIES.has(category) ? category : "other";
627
+ return { category: safeCategory, narrative };
628
+ } catch {
629
+ return null;
630
+ }
631
+ }
632
+
633
+ // src/pipeline/score.ts
634
+ var ALPHA = 0.3;
635
+ var REVERT_LOOKBACK = 7;
636
+ var BASE = {
637
+ feature: 0.5,
638
+ refactor: 0.4,
639
+ fix: 0.2,
640
+ chore: 0.1,
641
+ merge: 0,
642
+ other: 0,
643
+ wip: -0.3,
644
+ revert: -0.5
645
+ };
646
+ var TEST_PATH_RE = /(?:^|\/)(__tests__|tests?)\/|\.(?:test|spec)\.[jt]sx?$/i;
647
+ var REVERT_SHA_RE = /\b([0-9a-f]{7,40})\b/i;
648
+ function churnPenalty(churn) {
649
+ if (churn <= 100)
650
+ return 0;
651
+ const value = Math.log10(churn / 100) * 0.4;
652
+ return Math.min(value, 1.2);
653
+ }
654
+ function filesPenalty(count) {
655
+ return Math.min(count * 0.05, 0.5);
656
+ }
657
+ function testBonus(commit) {
658
+ return commit.files.some((f) => TEST_PATH_RE.test(f.path)) ? 0.2 : 0;
659
+ }
660
+ function wipPenalty(commit) {
661
+ return commit.riskFlags.includes("wip-message") ? 0.3 : 0;
662
+ }
663
+ function findRevertTarget(commit, byShortSha) {
664
+ if (commit.type !== "revert")
665
+ return null;
666
+ const match = commit.body.match(REVERT_SHA_RE) ?? commit.subject.match(REVERT_SHA_RE);
667
+ if (!match)
668
+ return null;
669
+ const sha = (match[1] ?? "").toLowerCase();
670
+ const short2 = sha.slice(0, 7);
671
+ const idx = byShortSha.get(short2);
672
+ return typeof idx === "number" ? idx : null;
673
+ }
674
+ function score(commits) {
675
+ const points = [];
676
+ const byShortSha = new Map;
677
+ let running = 0;
678
+ for (let i = 0;i < commits.length; i++) {
679
+ const commit = commits[i];
680
+ if (!commit)
681
+ continue;
682
+ const churn = commit.files.reduce((acc, f) => acc + f.added + f.deleted, 0);
683
+ const base = BASE[commit.type];
684
+ const delta = base - churnPenalty(churn) - filesPenalty(commit.files.length) - wipPenalty(commit) + testBonus(commit);
685
+ running = ALPHA * delta + (1 - ALPHA) * running;
686
+ points.push({ ...commit, delta, score: running, churn });
687
+ byShortSha.set(commit.sha.slice(0, 7), i);
688
+ }
689
+ let needsRecompute = false;
690
+ for (let i = 0;i < points.length; i++) {
691
+ const point = points[i];
692
+ if (!point)
693
+ continue;
694
+ const targetIdx = findRevertTarget(point, byShortSha);
695
+ if (targetIdx === null)
696
+ continue;
697
+ const distance = i - targetIdx;
698
+ if (distance <= 0 || distance > REVERT_LOOKBACK)
699
+ continue;
700
+ const target = points[targetIdx];
701
+ if (!target)
702
+ continue;
703
+ target.delta -= 0.5;
704
+ target.riskFlags = [...target.riskFlags, "later-reverted"];
705
+ needsRecompute = true;
706
+ }
707
+ if (needsRecompute) {
708
+ running = 0;
709
+ for (const point of points) {
710
+ running = ALPHA * point.delta + (1 - ALPHA) * running;
711
+ point.score = running;
712
+ }
713
+ }
714
+ return points;
715
+ }
716
+
717
+ // src/services/git-pathology-service.ts
718
+ var GIT_PATHOLOGY_SERVICE_NAME = "git_pathology";
719
+ var LOG_PREFIX2 = "[GitPathologyService]";
720
+ var DEFAULT_OPTIONS = {
721
+ since: "14d",
722
+ budget: 20,
723
+ cache: "auto"
724
+ };
725
+ function defaultBudget() {
726
+ const raw = process.env.GITPATHOLOGIST_BUDGET?.trim();
727
+ if (!raw)
728
+ return DEFAULT_OPTIONS.budget;
729
+ const parsed = Number.parseInt(raw, 10);
730
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_OPTIONS.budget;
731
+ }
732
+
733
+ class GitPathologyService extends Service {
734
+ static serviceType = GIT_PATHOLOGY_SERVICE_NAME;
735
+ capabilityDescription = "Forensic git-history analysis: per-surface health timeline, drift inflection detection, rot post-mortem.";
736
+ static async start(runtime) {
737
+ logger2.info(`${LOG_PREFIX2} starting`);
738
+ return new GitPathologyService(runtime);
739
+ }
740
+ async stop() {}
741
+ async runReport(surface, overrides = {}) {
742
+ const options = { ...DEFAULT_OPTIONS, budget: defaultBudget(), ...overrides };
743
+ const cacheDir = defaultCacheDir(surface.repoRoot);
744
+ const cache = createReportCache(cacheDir);
745
+ const cacheKey = makeCacheKey({ surface: surface.path, since: options.since });
746
+ const currentHead = headSha(surface.repoRoot);
747
+ if (options.cache !== "force") {
748
+ const cached = cache.read(cacheKey);
749
+ if (cached && cached.headSha === currentHead) {
750
+ logger2.info(`${LOG_PREFIX2} cache hit ${cacheKey.slice(0, 12)} surface=${surface.path}`);
751
+ return scrubSecretsDeep(cached);
752
+ }
753
+ if (options.cache === "read-only") {
754
+ throw new Error(`gitpathology cache miss for ${surface.path} (HEAD changed or no prior report)`);
755
+ }
756
+ }
757
+ const raw = scan(surface, { since: options.since });
758
+ if (raw.length === 0) {
759
+ const empty = scrubSecretsDeep(emptyReport(surface, options, currentHead, cacheKey));
760
+ cache.write(empty);
761
+ return empty;
762
+ }
763
+ const chronological = [...raw].reverse();
764
+ const classified = classify(chronological);
765
+ const points = score(classified);
766
+ const { peaks, drifts } = findInflections(points);
767
+ const { rotCauses, llmCalls } = await narrate(this.runtime ?? null, {
768
+ surfacePath: resolveSurfacePath(surface),
769
+ repoRoot: surface.repoRoot,
770
+ timeline: points,
771
+ drifts,
772
+ budget: options.budget
773
+ });
774
+ const oldest = points[0]?.date ?? new Date().toISOString();
775
+ const newest = points[points.length - 1]?.date ?? new Date().toISOString();
776
+ const authors = Array.from(new Set(points.map((p) => p.author))).sort();
777
+ const report = {
778
+ surface: surface.path,
779
+ repoRoot: surface.repoRoot,
780
+ window: { since: oldest, until: newest },
781
+ commitCount: points.length,
782
+ authors,
783
+ timeline: points,
784
+ peaks,
785
+ drifts,
786
+ rotCauses,
787
+ llmCalls,
788
+ headSha: currentHead,
789
+ generatedAt: new Date().toISOString(),
790
+ cacheKey
791
+ };
792
+ const safeReport = scrubSecretsDeep(report);
793
+ cache.write(safeReport);
794
+ logger2.info(`${LOG_PREFIX2} report written ${cacheKey.slice(0, 12)} surface=${surface.path} commits=${points.length} llm=${llmCalls}`);
795
+ return safeReport;
796
+ }
797
+ listReports(repoRoot) {
798
+ return createReportCache(defaultCacheDir(repoRoot)).list();
799
+ }
800
+ }
801
+ function emptyReport(surface, options, headSha2, cacheKey) {
802
+ const now = new Date().toISOString();
803
+ return {
804
+ surface: surface.path,
805
+ repoRoot: surface.repoRoot,
806
+ window: { since: options.since, until: now },
807
+ commitCount: 0,
808
+ authors: [],
809
+ timeline: [],
810
+ peaks: [],
811
+ drifts: [],
812
+ rotCauses: [],
813
+ llmCalls: 0,
814
+ headSha: headSha2,
815
+ generatedAt: now,
816
+ cacheKey
817
+ };
818
+ }
819
+
820
+ // src/actions/git-pathology.ts
821
+ var VALID_ACTIONS = new Set(["report", "list"]);
822
+ var SURFACE_HINT_RE = /\b(pathology|git\s+history|code\s+health|drift|rot|inflection|when\s+did\s+(?:this\s+)?(?:code|file|module|package|plugin|service|component|path|repo|repository|branch|commit))\b/i;
823
+ function getService(runtime) {
824
+ return runtime.getService(GIT_PATHOLOGY_SERVICE_NAME) ?? null;
825
+ }
826
+ function paramsRecord(options) {
827
+ if (!options || typeof options !== "object")
828
+ return {};
829
+ const parameters = options.parameters;
830
+ if (parameters && typeof parameters === "object") {
831
+ return parameters;
832
+ }
833
+ const params = options.params;
834
+ if (params && typeof params === "object")
835
+ return params;
836
+ return options;
837
+ }
838
+ function readAction(params) {
839
+ const rawValue = params.action;
840
+ const raw = typeof rawValue === "string" ? rawValue.toLowerCase() : "report";
841
+ return VALID_ACTIONS.has(raw) ? raw : "report";
842
+ }
843
+ function readString(params, key) {
844
+ const v = params[key];
845
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
846
+ }
847
+ function readNumber(params, key) {
848
+ const v = params[key];
849
+ if (typeof v === "number" && Number.isFinite(v))
850
+ return v;
851
+ if (typeof v === "string" && v.trim()) {
852
+ const parsed = Number.parseInt(v, 10);
853
+ return Number.isFinite(parsed) ? parsed : undefined;
854
+ }
855
+ return;
856
+ }
857
+ function buildOptions(params) {
858
+ const out = {};
859
+ const since = readString(params, "since");
860
+ if (since)
861
+ out.since = since;
862
+ const budget = readNumber(params, "budget");
863
+ if (typeof budget === "number")
864
+ out.budget = budget;
865
+ const cache = readString(params, "cache");
866
+ if (cache === "auto" || cache === "force" || cache === "read-only")
867
+ out.cache = cache;
868
+ return out;
869
+ }
870
+ function resolveRepoRoot() {
871
+ const fromEnv = process.env.ELIZA_WORKSPACE_DIR;
872
+ const cwd = fromEnv?.trim() ? fromEnv.trim() : process.cwd();
873
+ return path3.resolve(cwd);
874
+ }
875
+ function listResult(service, repoRoot) {
876
+ const summaries = service.listReports(repoRoot);
877
+ if (summaries.length === 0) {
878
+ return {
879
+ success: true,
880
+ text: "No cached pathology reports for this repo yet.",
881
+ data: { reports: [] }
882
+ };
883
+ }
884
+ const lines = summaries.map((s) => `- ${s.surface} (${s.commitCount} commits) — HEAD ${s.headSha.slice(0, 7)}, generated ${s.generatedAt}`);
885
+ return {
886
+ success: true,
887
+ text: `Cached pathology reports:
888
+ ${lines.join(`
889
+ `)}`,
890
+ data: { reports: summaries }
891
+ };
892
+ }
893
+ function reportResult(report) {
894
+ return {
895
+ success: true,
896
+ text: renderReport(report),
897
+ data: { report }
898
+ };
899
+ }
900
+ var gitPathologyAction = {
901
+ name: "GIT_PATHOLOGY",
902
+ similes: [
903
+ "ANALYZE_GIT_PATHOLOGY",
904
+ "GIT_HEALTH",
905
+ "GIT_FORENSICS",
906
+ "PATHOLOGY_REPORT",
907
+ "CODE_HISTORY_HEALTH",
908
+ "WHERE_DID_ROT_START"
909
+ ],
910
+ description: "Forensic git-history analysis for a path/glob surface. Returns peaks (peak quality moments), drift inflections (where rot started), and a post-mortem narrative. Use when the user asks 'when did this code get bad', 'where did rot start in X', or 'analyze git pathology for Y'. Actions: report (default), list (show cached reports).",
911
+ contexts: ["code", "git", "general"],
912
+ suppressPostActionContinuation: true,
913
+ parameters: [
914
+ {
915
+ name: "action",
916
+ description: "Which gitpathologist action: report or list. Default: report.",
917
+ required: false,
918
+ schema: { type: "string", enum: ["report", "list"] }
919
+ },
920
+ {
921
+ name: "surface",
922
+ description: "Path or glob to analyze (relative to repo root). Required for action=report.",
923
+ required: false,
924
+ schema: { type: "string" }
925
+ },
926
+ {
927
+ name: "since",
928
+ description: "Lookback window. ISO date or relative (e.g. '14d', '4w'). Default '14d'.",
929
+ required: false,
930
+ schema: { type: "string" }
931
+ },
932
+ {
933
+ name: "budget",
934
+ description: "Max LLM narration calls per analysis. Default 20.",
935
+ required: false,
936
+ schema: { type: "integer", minimum: 0 }
937
+ },
938
+ {
939
+ name: "cache",
940
+ description: "Cache policy: auto (default), force (recompute), read-only (fail on miss).",
941
+ required: false,
942
+ schema: { type: "string", enum: ["auto", "force", "read-only"] }
943
+ }
944
+ ],
945
+ validate: async (runtime, message) => {
946
+ if (!getService(runtime))
947
+ return false;
948
+ const content = message.content;
949
+ const params = content.params && typeof content.params === "object" ? content.params : null;
950
+ if (params && typeof params.action === "string") {
951
+ return true;
952
+ }
953
+ if (params && typeof params.surface === "string")
954
+ return true;
955
+ const text = typeof content.text === "string" ? content.text : "";
956
+ return SURFACE_HINT_RE.test(text);
957
+ },
958
+ handler: async (runtime, _message, _state, options, callback) => {
959
+ const service = getService(runtime);
960
+ if (!service) {
961
+ const text = "GitPathologyService not registered on runtime.";
962
+ return { success: false, text, error: "SERVICE_UNAVAILABLE" };
963
+ }
964
+ const params = paramsRecord(options);
965
+ const action = readAction(params);
966
+ const repoRoot = resolveRepoRoot();
967
+ if (action === "list") {
968
+ const result2 = listResult(service, repoRoot);
969
+ if (callback && typeof result2.text === "string") {
970
+ await callback({ text: result2.text });
971
+ }
972
+ return result2;
973
+ }
974
+ const surfacePath = readString(params, "surface");
975
+ if (!surfacePath) {
976
+ const text = "action=report requires a `surface` param (path or glob relative to repo root).";
977
+ if (callback)
978
+ await callback({ text });
979
+ return { success: false, text, error: "MISSING_SURFACE" };
980
+ }
981
+ const surface = { path: surfacePath, repoRoot };
982
+ const overrides = buildOptions(params);
983
+ let report;
984
+ try {
985
+ report = await service.runReport(surface, overrides);
986
+ } catch (err) {
987
+ const text = `Git pathology analysis failed: ${err.message}`;
988
+ if (callback)
989
+ await callback({ text });
990
+ return { success: false, text, error: "ANALYSIS_FAILED" };
991
+ }
992
+ const result = reportResult(report);
993
+ if (callback && typeof result.text === "string") {
994
+ await callback({ text: result.text });
995
+ }
996
+ return result;
997
+ }
998
+ };
999
+
1000
+ // src/index.ts
1001
+ var gitpathologistPlugin = {
1002
+ name: "@elizaos/plugin-gitpathologist",
1003
+ description: "Forensic git-history analysis for elizaOS agents: per-surface health timeline, drift inflection detection, rot post-mortem.",
1004
+ init: async (_config, _runtime) => {
1005
+ logger3.info("[GitPathology] plugin initialized");
1006
+ },
1007
+ async dispose(runtime) {
1008
+ const svc = runtime.getService(GIT_PATHOLOGY_SERVICE_NAME);
1009
+ await svc?.stop();
1010
+ },
1011
+ services: [GitPathologyService],
1012
+ actions: [gitPathologyAction],
1013
+ providers: []
1014
+ };
1015
+ var src_default = gitpathologistPlugin;
1016
+ export {
1017
+ gitPathologyAction,
1018
+ src_default as default,
1019
+ GitPathologyService,
1020
+ GIT_PATHOLOGY_SERVICE_NAME
1021
+ };
1022
+
1023
+ //# debugId=1234025F431646EF64756E2164756E21