chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
# @elizaos/plugin-gitpathologist
Forensic git-history analysis for Eliza agents: per-surface health timeline, drift inflection detection, and LLM-narrated rot post-mortem.
## Purpose / role
This plugin adds git repository forensics to any Eliza agent running in a Node.js environment. It answers questions like "when did this code get bad?" or "where did rot start in `src/payments/`?" by walking `git log`, scoring each commit, detecting quality inflection points, and optionally calling the agent's configured small-text model to write a narrative explanation.
The plugin is opt-in: the elizaOS agent's plugin collector auto-loads the package when the workspace has a `.git` directory (and the platform is not mobile), unless `ELIZA_GITPATHOLOGIST` overrides that decision. `ELIZA_GITPATHOLOGIST`, `GITPATHOLOGIST_BUDGET`, and `GITPATHOLOGIST_CACHE_DIR` are all declared in the package's `agentConfig.pluginParameters`, but only `GITPATHOLOGIST_BUDGET` and `GITPATHOLOGIST_CACHE_DIR` are read inside this package; `ELIZA_GITPATHOLOGIST` is consumed by the agent runtime's plugin collector. The service itself performs no `.git` detection — `GitPathologyService.start()` registers unconditionally once the package is loaded.
## Plugin surface
| Kind | Name | What it does |
|---|---|---|
| **Service** | `git_pathology` (`GIT_PATHOLOGY_SERVICE_NAME`) | On-demand analysis orchestrator. Registers the pipeline and cache; all action calls go through it. |
| **Action** | `GIT_PATHOLOGY` | Multiplex action: `action=report` runs a full pathology analysis for a surface path/glob; `action=list` lists cached reports. Similes: `ANALYZE_GIT_PATHOLOGY`, `GIT_HEALTH`, `GIT_FORENSICS`, `PATHOLOGY_REPORT`, `CODE_HISTORY_HEALTH`, `WHERE_DID_ROT_START`. |
No providers, evaluators, routes, or event handlers are registered.
## Layout
```
src/
index.ts Plugin object export; wires service + action
types.ts All shared types: SurfaceSpec, AnalysisOptions,
RawCommit, ClassifiedCommit, CommitHealthPoint,
InflectionPoint, RotCause, PathologyReport,
CachedReportSummary, Operation
render.ts Markdown renderer: PathologyReport → string
secret-scrubber.ts Regex-based secret redaction (API keys, tokens,
bearer headers, env-name=value patterns)
actions/
git-pathology.ts GIT_PATHOLOGY action handler and parameter parsing
services/
git-pathology-service.ts GitPathologyService class; coordinates pipeline
pipeline/
scan.ts Step 1 — git log parsing (deterministic, no LLM)
classify.ts Step 2 — rule-based commit type + risk-flag tagging
score.ts Step 3 — per-commit health delta + EMA scoring
inflect.ts Step 4 — peak and drift inflection detection
narrate.ts Step 5 — LLM narration for drift commits (optional)
cache/
report-cache.ts Disk-backed JSON cache keyed by sha256(surface + '\0' + since)
```
## Pipeline (scan → classify → score → inflect → narrate)
1. **scan** — calls `git log --numstat` once for the surface path/glob. No LLM. Returns `RawCommit[]`.
2. **classify** — regex + prefix rules assign `CommitType` and `riskFlags` to each commit. Returns `ClassifiedCommit[]`.
3. **score** — deterministic EMA scoring (α=0.3). Each commit gets a `delta` and running `score`. Reverts targeting commits within the lookback window apply a proximity penalty. Returns `CommitHealthPoint[]`.
4. **inflect** — detects peaks (local EMA maxima) and drift onsets (sustained score drops ≥0.25 over 5 commits). Returns up to 5 peaks and 5 drifts as `InflectionPoint[]`.
5. **narrate** — one `ModelType.TEXT_SMALL` call per drift, capped by `budget`. Fetches `git show` diff snippets (up to 8 KB, scrubbed of secrets) and asks the model to classify a `RotCategory` and write a 2-3 sentence narrative. Falls back to deterministic narration when the runtime has no model or `budget=0`.
Reports are cached by key = `sha256(surface + '\0' + since)`. A cache hit is only valid when the repo HEAD sha matches the stored `headSha`.
## Commands
All scripts require a built `dist/` (run `build` first or use the repo-level turborepo pipeline).
```bash
bun run --cwd plugins/plugin-gitpathologist build # Bun.build (ESM + CJS) + tsc declarations
bun run --cwd plugins/plugin-gitpathologist test # vitest run
bun run --cwd plugins/plugin-gitpathologist typecheck # tsgo --noEmit
bun run --cwd plugins/plugin-gitpathologist lint # biome check --write --unsafe
bun run --cwd plugins/plugin-gitpathologist lint:check # biome check (no write)
bun run --cwd plugins/plugin-gitpathologist format # biome format --write
bun run --cwd plugins/plugin-gitpathologist format:check # biome format (no write)
bun run --cwd plugins/plugin-gitpathologist clean # rm dist .turbo
```
## Config / env vars
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
| `ELIZA_GITPATHOLOGIST` | boolean | No | auto | Force-enable (`true`) or force-disable (`false`) the plugin. When unset, plugin auto-enables if a `.git` directory is present. |
| `GITPATHOLOGIST_BUDGET` | number | No | `20` | Maximum LLM narration calls per analysis. Set to `0` to run fully deterministic (no model calls). |
| `GITPATHOLOGIST_CACHE_DIR` | string | No | `<repoRoot>/.eliza/gitpathology` | Override the cache directory. Relative paths are resolved against `repoRoot`. |
The action reads `ELIZA_WORKSPACE_DIR` to resolve the repo root when running inside an Eliza agent workspace. Falls back to `process.cwd()` when it is unset.
## How to extend
### Add an action
1. Create `src/actions/<name>.ts` exporting a typed `Action` object.
2. Import it in `src/index.ts` and append it to `gitpathologistPlugin.actions`.
3. Add a test in `__tests__/`.
### Add a pipeline phase
1. Create `src/pipeline/<phase>.ts` with a pure function `(input: PreviousOutput[]) => NextOutput[]`.
2. Call it in `GitPathologyService.runReport()` (`src/services/git-pathology-service.ts`) after the appropriate existing phase.
3. Add or extend types in `src/types.ts` and re-export from `src/index.ts` if callers need the type.
### Add a provider
1. Create `src/providers/<name>.ts` exporting a typed `Provider` object.
2. Import and push into `gitpathologistPlugin.providers` (currently an empty array).
## Conventions / gotchas
- **`git` binary required at runtime.** The scan phase calls `spawnSync("git", ...)` directly. The plugin will fail to start meaningfully in environments without `git` on `PATH`.
- **Node-only.** `exports.node` is the sole distribution target. Do not add browser-compatible code paths.
- **Secret scrubbing is mandatory before LLM calls and cache writes.** `narrate()` runs `scrubSecrets` (string version) over every commit subject and diff snippet before they reach the model prompt; the service runs `scrubSecretsDeep` over the whole `PathologyReport` (including cached reads) before returning or writing to disk. Never bypass either.
- **Cache is HEAD-keyed, not time-keyed.** Any commit to the repo invalidates existing cache entries for that surface. This is intentional.
- **Narrate falls back gracefully.** If the runtime provides no `useModel` function or `budget=0`, `narrate()` uses `deterministicRotCause()` and logs a warning. The report is still valid.
- **No background work.** The service has no timers or subscriptions. Everything is on-demand via `runReport()`.
- **Logging prefix:** `[GitPathologyService]`, `[GitPathology/narrate]`. Use `logger` from `@elizaos/core` for all log output; see root AGENTS.md for the logger-only rule.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — CLI / tooling:**
- The real command/flow invocation transcript (args in, stdout/stderr, exit code) and the artifacts it generated (files, scaffolds, manifests, screenshots/recordings).
- Failure paths: bad args, missing deps, partial state, permission/network errors.
- A recording/log of the actual run end to end — not a unit test of one helper.
- Any model interaction captured as a live trajectory and reviewed.
<!-- END: evidence-and-e2e-mandate -->
+150
View File
@@ -0,0 +1,150 @@
# @elizaos/plugin-gitpathologist
Forensic git-history analysis for Eliza agents: per-surface health timeline, drift inflection detection, and LLM-narrated rot post-mortem.
## Purpose / role
This plugin adds git repository forensics to any Eliza agent running in a Node.js environment. It answers questions like "when did this code get bad?" or "where did rot start in `src/payments/`?" by walking `git log`, scoring each commit, detecting quality inflection points, and optionally calling the agent's configured small-text model to write a narrative explanation.
The plugin is opt-in: the elizaOS agent's plugin collector auto-loads the package when the workspace has a `.git` directory (and the platform is not mobile), unless `ELIZA_GITPATHOLOGIST` overrides that decision. `ELIZA_GITPATHOLOGIST`, `GITPATHOLOGIST_BUDGET`, and `GITPATHOLOGIST_CACHE_DIR` are all declared in the package's `agentConfig.pluginParameters`, but only `GITPATHOLOGIST_BUDGET` and `GITPATHOLOGIST_CACHE_DIR` are read inside this package; `ELIZA_GITPATHOLOGIST` is consumed by the agent runtime's plugin collector. The service itself performs no `.git` detection — `GitPathologyService.start()` registers unconditionally once the package is loaded.
## Plugin surface
| Kind | Name | What it does |
|---|---|---|
| **Service** | `git_pathology` (`GIT_PATHOLOGY_SERVICE_NAME`) | On-demand analysis orchestrator. Registers the pipeline and cache; all action calls go through it. |
| **Action** | `GIT_PATHOLOGY` | Multiplex action: `action=report` runs a full pathology analysis for a surface path/glob; `action=list` lists cached reports. Similes: `ANALYZE_GIT_PATHOLOGY`, `GIT_HEALTH`, `GIT_FORENSICS`, `PATHOLOGY_REPORT`, `CODE_HISTORY_HEALTH`, `WHERE_DID_ROT_START`. |
No providers, evaluators, routes, or event handlers are registered.
## Layout
```
src/
index.ts Plugin object export; wires service + action
types.ts All shared types: SurfaceSpec, AnalysisOptions,
RawCommit, ClassifiedCommit, CommitHealthPoint,
InflectionPoint, RotCause, PathologyReport,
CachedReportSummary, Operation
render.ts Markdown renderer: PathologyReport → string
secret-scrubber.ts Regex-based secret redaction (API keys, tokens,
bearer headers, env-name=value patterns)
actions/
git-pathology.ts GIT_PATHOLOGY action handler and parameter parsing
services/
git-pathology-service.ts GitPathologyService class; coordinates pipeline
pipeline/
scan.ts Step 1 — git log parsing (deterministic, no LLM)
classify.ts Step 2 — rule-based commit type + risk-flag tagging
score.ts Step 3 — per-commit health delta + EMA scoring
inflect.ts Step 4 — peak and drift inflection detection
narrate.ts Step 5 — LLM narration for drift commits (optional)
cache/
report-cache.ts Disk-backed JSON cache keyed by sha256(surface + '\0' + since)
```
## Pipeline (scan → classify → score → inflect → narrate)
1. **scan** — calls `git log --numstat` once for the surface path/glob. No LLM. Returns `RawCommit[]`.
2. **classify** — regex + prefix rules assign `CommitType` and `riskFlags` to each commit. Returns `ClassifiedCommit[]`.
3. **score** — deterministic EMA scoring (α=0.3). Each commit gets a `delta` and running `score`. Reverts targeting commits within the lookback window apply a proximity penalty. Returns `CommitHealthPoint[]`.
4. **inflect** — detects peaks (local EMA maxima) and drift onsets (sustained score drops ≥0.25 over 5 commits). Returns up to 5 peaks and 5 drifts as `InflectionPoint[]`.
5. **narrate** — one `ModelType.TEXT_SMALL` call per drift, capped by `budget`. Fetches `git show` diff snippets (up to 8 KB, scrubbed of secrets) and asks the model to classify a `RotCategory` and write a 2-3 sentence narrative. Falls back to deterministic narration when the runtime has no model or `budget=0`.
Reports are cached by key = `sha256(surface + '\0' + since)`. A cache hit is only valid when the repo HEAD sha matches the stored `headSha`.
## Commands
All scripts require a built `dist/` (run `build` first or use the repo-level turborepo pipeline).
```bash
bun run --cwd plugins/plugin-gitpathologist build # Bun.build (ESM + CJS) + tsc declarations
bun run --cwd plugins/plugin-gitpathologist test # vitest run
bun run --cwd plugins/plugin-gitpathologist typecheck # tsgo --noEmit
bun run --cwd plugins/plugin-gitpathologist lint # biome check --write --unsafe
bun run --cwd plugins/plugin-gitpathologist lint:check # biome check (no write)
bun run --cwd plugins/plugin-gitpathologist format # biome format --write
bun run --cwd plugins/plugin-gitpathologist format:check # biome format (no write)
bun run --cwd plugins/plugin-gitpathologist clean # rm dist .turbo
```
## Config / env vars
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
| `ELIZA_GITPATHOLOGIST` | boolean | No | auto | Force-enable (`true`) or force-disable (`false`) the plugin. When unset, plugin auto-enables if a `.git` directory is present. |
| `GITPATHOLOGIST_BUDGET` | number | No | `20` | Maximum LLM narration calls per analysis. Set to `0` to run fully deterministic (no model calls). |
| `GITPATHOLOGIST_CACHE_DIR` | string | No | `<repoRoot>/.eliza/gitpathology` | Override the cache directory. Relative paths are resolved against `repoRoot`. |
The action reads `ELIZA_WORKSPACE_DIR` to resolve the repo root when running inside an Eliza agent workspace. Falls back to `process.cwd()` when it is unset.
## How to extend
### Add an action
1. Create `src/actions/<name>.ts` exporting a typed `Action` object.
2. Import it in `src/index.ts` and append it to `gitpathologistPlugin.actions`.
3. Add a test in `__tests__/`.
### Add a pipeline phase
1. Create `src/pipeline/<phase>.ts` with a pure function `(input: PreviousOutput[]) => NextOutput[]`.
2. Call it in `GitPathologyService.runReport()` (`src/services/git-pathology-service.ts`) after the appropriate existing phase.
3. Add or extend types in `src/types.ts` and re-export from `src/index.ts` if callers need the type.
### Add a provider
1. Create `src/providers/<name>.ts` exporting a typed `Provider` object.
2. Import and push into `gitpathologistPlugin.providers` (currently an empty array).
## Conventions / gotchas
- **`git` binary required at runtime.** The scan phase calls `spawnSync("git", ...)` directly. The plugin will fail to start meaningfully in environments without `git` on `PATH`.
- **Node-only.** `exports.node` is the sole distribution target. Do not add browser-compatible code paths.
- **Secret scrubbing is mandatory before LLM calls and cache writes.** `narrate()` runs `scrubSecrets` (string version) over every commit subject and diff snippet before they reach the model prompt; the service runs `scrubSecretsDeep` over the whole `PathologyReport` (including cached reads) before returning or writing to disk. Never bypass either.
- **Cache is HEAD-keyed, not time-keyed.** Any commit to the repo invalidates existing cache entries for that surface. This is intentional.
- **Narrate falls back gracefully.** If the runtime provides no `useModel` function or `budget=0`, `narrate()` uses `deterministicRotCause()` and logs a warning. The report is still valid.
- **No background work.** The service has no timers or subscriptions. Everything is on-demand via `runReport()`.
- **Logging prefix:** `[GitPathologyService]`, `[GitPathology/narrate]`. Use `logger` from `@elizaos/core` for all log output; see root AGENTS.md for the logger-only rule.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — CLI / tooling:**
- The real command/flow invocation transcript (args in, stdout/stderr, exit code) and the artifacts it generated (files, scaffolds, manifests, screenshots/recordings).
- Failure paths: bad args, missing deps, partial state, permission/network errors.
- A recording/log of the actual run end to end — not a unit test of one helper.
- Any model interaction captured as a live trajectory and reviewed.
<!-- END: evidence-and-e2e-mandate -->
+101
View File
@@ -0,0 +1,101 @@
# @elizaos/plugin-gitpathologist
Forensic git-history analysis for elizaOS agents.
## What it does
This plugin gives an Eliza agent the ability to analyze the commit history of a file, directory, or path glob and produce a structured health report. It answers questions such as:
- "When did the code quality in `src/payments/` start degrading?"
- "Where did rot begin in this module?"
- "What does the quality timeline look like for the last month?"
The analysis runs a five-phase pipeline: parse `git log`, classify each commit by type, score commits on a health scale using an exponential moving average, detect quality peaks and drift inflections, then (optionally) call the agent's configured text model to write a narrative post-mortem for each drift event.
Reports are cached on disk by `sha256(surface + since)` and validated against the repository HEAD sha, so a repeat query at the same commit returns the cached report instantly, while any new commit invalidates it.
## Capabilities
### Action: `GIT_PATHOLOGY`
The plugin registers a single multiplex action with two operations:
**`action=report`** (default) — full pathology analysis for a surface.
Parameters:
| Parameter | Required | Default | Description |
|---|---|---|---|
| `surface` | Yes | — | Path or glob relative to repo root (e.g. `src/payments/`, `**/*.test.ts`) |
| `since` | No | `14d` | Lookback window. ISO date or relative (`14d`, `4w`, `2m`). |
| `budget` | No | `20` | Max LLM narration calls. `0` = fully deterministic, no model calls. |
| `cache` | No | `auto` | `auto` (use cache when HEAD matches), `force` (recompute), `read-only` (fail on miss). |
Output: a Markdown report with:
- Commit count, authors, and analysis window
- **Peaks** — local maxima of health score (best moments in the window)
- **Drift inflections** — commits where a sustained quality drop begins
- **Rot post-mortem** — per-drift narrative (LLM-generated if model available, deterministic otherwise)
**`action=list`** — list cached reports for the current repo root. No parameters required.
### Trigger phrases
The agent will activate this action when the user says things like:
- "analyze git pathology for X"
- "when did this code get bad"
- "where did rot start in X"
- "code health for X"
- "drift analysis for X"
## Configuration
| Environment variable | Default | Description |
|---|---|---|
| `ELIZA_GITPATHOLOGIST` | auto | Set `true` to force-enable, `false` to force-disable. When unset, the plugin auto-enables in workspaces with a `.git` directory. |
| `GITPATHOLOGIST_BUDGET` | `20` | Maximum LLM narration calls per analysis. Set to `0` to use deterministic narration only. |
| `GITPATHOLOGIST_CACHE_DIR` | `<repoRoot>/.eliza/gitpathology` | Override the cache directory for pathology reports. |
## Requirements
- Node.js runtime (not available on mobile/browser targets).
- `git` must be present on `PATH`.
- An Eliza agent runtime with a configured `TEXT_SMALL` model for LLM narration (optional — the plugin degrades gracefully to deterministic narration without one).
## Enabling the plugin
Add `@elizaos/plugin-gitpathologist` to your agent's plugin list:
```ts
import gitpathologistPlugin from "@elizaos/plugin-gitpathologist";
const agent = new AgentRuntime({
plugins: [gitpathologistPlugin],
// ...
});
```
## Example output
```
# Git Pathology — `src/payments/`
**Repo:** `/home/user/myproject`
**Window:** 2025-05-01 → 2025-05-31
**HEAD:** `a1b2c3d`
**Commits analyzed:** 47 (alice, bob, carol)
**LLM calls:** 3
## Peaks (local maxima of health)
- `f3a1b2c` (2025-05-10, alice): score 0.61 +0.40 — clean refactor, low churn
## Drift inflections (sustained downturns)
- `9d2c4e1` (2025-05-22, bob): score 0.12 -0.49 — score drops 0.49 over next 5 commits flags=large-churn
## Rot post-mortem
### churn-spiral — `9d2c4e1`..`7a3f9b0`
This commit introduced 1,240 churn lines across 23 files with no test coverage changes, signaling a large undisciplined patch...
```
@@ -0,0 +1,47 @@
/**
* Covers gitPathologyAction.validate: it claims code-history prompts and
* explicit surface/action params but declines generic "when did" questions.
* Uses a stub runtime object; no real service, git, or model.
*/
import { describe, expect, it } from "vitest";
import { gitPathologyAction } from "../src/actions/git-pathology.ts";
const runtimeWithService = {
getService: () => ({}),
} as never;
function message(text: string, params?: Record<string, unknown>) {
return {
content: {
text,
...(params ? { params } : {}),
},
} as never;
}
describe("gitPathologyAction validation", () => {
it("does not claim broad non-git 'when did' questions", async () => {
await expect(
gitPathologyAction.validate?.(
runtimeWithService,
message("when did we decide on the launch name?")
)
).resolves.toBe(false);
});
it("claims code-history prompts and explicit params", async () => {
await expect(
gitPathologyAction.validate?.(
runtimeWithService,
message("when did this module start to rot?")
)
).resolves.toBe(true);
await expect(
gitPathologyAction.validate?.(
runtimeWithService,
message("please analyze", { surface: "src" })
)
).resolves.toBe(true);
});
});
@@ -0,0 +1,125 @@
/**
* Exercises the on-disk report cache and makeCacheKey against a real temp
* directory: round-trip read/write, atomic-write cleanup, malformed-file
* tolerance, newest-first listing, and HEAD-sha freshness.
*/
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createReportCache, makeCacheKey } from "../src/cache/report-cache.ts";
import type { PathologyReport } from "../src/types.ts";
function sampleReport(overrides: Partial<PathologyReport> = {}): PathologyReport {
const cacheKey = makeCacheKey({ surface: "src/api", since: "14d" });
return {
surface: "src/api",
repoRoot: "/repo",
window: { since: "2026-04-01T00:00:00Z", until: "2026-04-15T00:00:00Z" },
commitCount: 3,
authors: ["alice"],
timeline: [],
peaks: [],
drifts: [],
rotCauses: [],
llmCalls: 0,
headSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
generatedAt: "2026-04-15T10:00:00Z",
cacheKey,
...overrides,
};
}
describe("makeCacheKey", () => {
it("is stable for the same input", () => {
const a = makeCacheKey({ surface: "src/api", since: "14d" });
const b = makeCacheKey({ surface: "src/api", since: "14d" });
expect(a).toBe(b);
});
it("differs when surface or since differs", () => {
expect(makeCacheKey({ surface: "src/api", since: "14d" })).not.toBe(
makeCacheKey({ surface: "src/api", since: "7d" })
);
expect(makeCacheKey({ surface: "src/api", since: "14d" })).not.toBe(
makeCacheKey({ surface: "src/ui", since: "14d" })
);
});
});
describe("createReportCache", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), "gitpath-cache-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("reads back what it writes", () => {
const cache = createReportCache(dir);
const report = sampleReport();
cache.write(report);
const back = cache.read(report.cacheKey);
expect(back).toEqual(report);
});
it("does not leave transient files after a successful write", () => {
const cache = createReportCache(dir);
cache.write(sampleReport());
expect(readdirSync(dir).filter((file) => file.endsWith(".tmp"))).toEqual([]);
});
it("returns null on miss", () => {
const cache = createReportCache(dir);
expect(cache.read("nonexistent")).toBeNull();
});
it("treats malformed cache files as misses", () => {
const cache = createReportCache(dir);
writeFileSync(path.join(dir, "bad.json"), "{not json", "utf8");
expect(cache.read("bad")).toBeNull();
});
it("lists reports sorted newest-first", () => {
const cache = createReportCache(dir);
cache.write(
sampleReport({
cacheKey: "a",
surface: "old",
generatedAt: "2026-04-01T00:00:00Z",
})
);
cache.write(
sampleReport({
cacheKey: "b",
surface: "new",
generatedAt: "2026-04-10T00:00:00Z",
})
);
const list = cache.list();
expect(list.length).toBe(2);
expect(list[0]?.surface).toBe("new");
expect(list[1]?.surface).toBe("old");
});
it("ignores malformed cache files when listing reports", () => {
const cache = createReportCache(dir);
cache.write(sampleReport({ cacheKey: "good", surface: "ok" }));
writeFileSync(path.join(dir, "bad.json"), "{not json", "utf8");
expect(cache.list().map((entry) => entry.surface)).toEqual(["ok"]);
});
it("isFreshFor returns true only when HEAD matches", () => {
const cache = createReportCache(dir);
const head = "1111111111111111111111111111111111111111";
cache.write(sampleReport({ headSha: head }));
const key = makeCacheKey({ surface: "src/api", since: "14d" });
expect(cache.isFreshFor(key, head)).toBe(true);
expect(cache.isFreshFor(key, "2222222222222222222222222222222222222222")).toBe(false);
});
});
@@ -0,0 +1,91 @@
/**
* Covers the rule-based commit classifier: conventional-prefix mapping,
* WIP/revert/merge detection, and risk flags (large-churn, wide-blast,
* breaking). Pure functions, no git or model.
*/
import { describe, expect, it } from "vitest";
import { classify, classifyOne } from "../src/pipeline/classify.ts";
import type { RawCommit } from "../src/types.ts";
function baseCommit(subject: string, overrides: Partial<RawCommit> = {}): RawCommit {
return {
sha: "abc1234abc1234abc1234abc1234abc1234abcd",
parents: ["00000000"],
author: "alice",
authorEmail: "alice@example.com",
date: "2026-04-01T10:00:00Z",
subject,
body: "",
files: [{ path: "src/x.ts", added: 5, deleted: 1, status: "M" }],
diffSnippet: "",
...overrides,
};
}
describe("classifyOne", () => {
it("recognizes conventional commit prefixes", () => {
expect(classifyOne(baseCommit("feat: add login")).type).toBe("feature");
expect(classifyOne(baseCommit("fix: handle null")).type).toBe("fix");
expect(classifyOne(baseCommit("refactor: extract helper")).type).toBe("refactor");
expect(classifyOne(baseCommit("chore: bump deps")).type).toBe("chore");
expect(classifyOne(baseCommit("perf: cache hot path")).type).toBe("refactor");
expect(classifyOne(baseCommit("docs: update README")).type).toBe("chore");
});
it("flags WIP commits with risk", () => {
const c = classifyOne(baseCommit("wip: still working"));
expect(c.type).toBe("wip");
expect(c.riskFlags).toContain("wip-message");
});
it("identifies reverts", () => {
const c = classifyOne(baseCommit('Revert "feat: bad idea"'));
expect(c.type).toBe("revert");
expect(c.riskFlags).toContain("revert-subject");
});
it("identifies merges by parent count", () => {
const c = classifyOne(baseCommit("Merge branch foo", { parents: ["a", "b"] }));
expect(c.type).toBe("merge");
});
it("flags large churn", () => {
const c = classifyOne(
baseCommit("feat: big feature", {
files: [{ path: "src/big.ts", added: 700, deleted: 50, status: "M" }],
})
);
expect(c.riskFlags).toContain("large-churn");
});
it("flags wide blast radius", () => {
const files = Array.from({ length: 25 }, (_, i) => ({
path: `src/f${i}.ts`,
added: 1,
deleted: 0,
status: "M" as const,
}));
const c = classifyOne(baseCommit("chore: sweep", { files }));
expect(c.riskFlags).toContain("wide-blast");
});
it("flags breaking changes via ! marker", () => {
const c = classifyOne(baseCommit("feat!: drop legacy API"));
expect(c.type).toBe("feature");
expect(c.riskFlags).toContain("breaking");
});
it("falls back to other for opaque subjects", () => {
expect(classifyOne(baseCommit("did stuff")).type).toBe("other");
});
});
describe("classify (batch)", () => {
it("preserves order and classifies all", () => {
const input = [baseCommit("feat: a"), baseCommit("fix: b"), baseCommit("wip: c")];
const out = classify(input);
expect(out.map((c) => c.type)).toEqual(["feature", "fix", "wip"]);
expect(out.every((c) => c.classifiedBy === "rule")).toBe(true);
});
});
@@ -0,0 +1,121 @@
/**
* End-to-end pipeline test against the toy git repo.
*
* Exercises scan → classify → score → findInflections → cache write/read,
* directly composed without GitPathologyService — the Service is just a thin
* wrapper that adds runtime/`useModel` plumbing, and importing it pulls in
* @elizaos/core whose generated codegen isn't always present in a fresh
* worktree. The composition under test is what produces the report content.
*/
import { existsSync, rmSync } from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createReportCache, defaultCacheDir, makeCacheKey } from "../src/cache/report-cache.ts";
import { classify } from "../src/pipeline/classify.ts";
import { findInflections } from "../src/pipeline/inflect.ts";
import { headSha as readHeadSha, scan } from "../src/pipeline/scan.ts";
import { score } from "../src/pipeline/score.ts";
import type { PathologyReport, SurfaceSpec } from "../src/types.ts";
import { buildToyRepo, type ToyRepoSpec } from "./toy-repo.ts";
function runPipeline(surface: SurfaceSpec, since: string): PathologyReport {
const raw = scan(surface, { since });
const chronological = [...raw].reverse();
const classified = classify(chronological);
const points = score(classified);
const { peaks, drifts } = findInflections(points);
const oldest = points[0]?.date ?? new Date().toISOString();
const newest = points[points.length - 1]?.date ?? new Date().toISOString();
const authors = Array.from(new Set(points.map((p) => p.author))).sort();
const cacheKey = makeCacheKey({ surface: surface.path, since });
return {
surface: surface.path,
repoRoot: surface.repoRoot,
window: { since: oldest, until: newest },
commitCount: points.length,
authors,
timeline: points,
peaks,
drifts,
rotCauses: [],
llmCalls: 0,
headSha: readHeadSha(surface.repoRoot),
generatedAt: new Date().toISOString(),
cacheKey,
};
}
describe("pipeline end-to-end on toy repo", () => {
let toy: ToyRepoSpec;
beforeAll(() => {
toy = buildToyRepo();
}, 30_000);
afterAll(() => {
rmSync(toy.repoRoot, { recursive: true, force: true });
});
it("produces a full report with peaks and drifts", () => {
const report = runPipeline({ path: toy.surface, repoRoot: toy.repoRoot }, "1 year ago");
expect(report.commitCount).toBeGreaterThan(10);
expect(report.timeline.length).toBe(report.commitCount);
expect(report.headSha).toMatch(/^[0-9a-f]{40}$/);
expect(report.peaks.length).toBeGreaterThanOrEqual(1);
expect(report.drifts.length).toBeGreaterThanOrEqual(1);
});
it("classifies the WIP dump as type=wip with risk flag", () => {
const report = runPipeline({ path: toy.surface, repoRoot: toy.repoRoot }, "1 year ago");
const wip = report.timeline.find((p) => p.subject.startsWith("wip"));
expect(wip).toBeDefined();
expect(wip?.type).toBe("wip");
expect(wip?.riskFlags).toContain("wip-message");
expect(wip?.churn).toBeGreaterThan(100);
});
it("scores phase-A clean features higher than the WIP dump", () => {
const report = runPipeline({ path: toy.surface, repoRoot: toy.repoRoot }, "1 year ago");
const wipDelta = report.timeline.find((p) => p.subject.startsWith("wip"))?.delta ?? 0;
const featDeltas = report.timeline
.filter((p) => p.subject.startsWith("feat"))
.map((p) => p.delta);
expect(featDeltas.length).toBeGreaterThan(0);
const featAvg = featDeltas.reduce((a, b) => a + b, 0) / featDeltas.length;
expect(featAvg).toBeGreaterThan(wipDelta);
});
it("flags the WIP commit as a drift inflection (or detects drift after it)", () => {
const report = runPipeline({ path: toy.surface, repoRoot: toy.repoRoot }, "1 year ago");
const wipSha = toy.commitsByPhase.B[0];
expect(wipSha).toBeDefined();
const wipIdx = report.timeline.findIndex((p) => p.sha === wipSha);
const driftIdxes = report.drifts
.map((d) => report.timeline.findIndex((p) => p.sha === d.sha))
.filter((i) => i >= 0);
const driftNearWip = driftIdxes.some((idx) => Math.abs(idx - wipIdx) <= 2);
expect(driftNearWip || report.drifts.length > 0).toBe(true);
});
it("round-trips through the on-disk cache", () => {
const report = runPipeline({ path: toy.surface, repoRoot: toy.repoRoot }, "1 year ago");
const cache = createReportCache(defaultCacheDir(toy.repoRoot));
cache.write(report);
const back = cache.read(report.cacheKey);
expect(back).toEqual(report);
// Use cache.dir (the actual directory used) rather than re-computing
// defaultCacheDir, which is env-dependent and may differ if
// GITPATHOLOGIST_CACHE_DIR is set in the CI environment.
const cachePath = path.join(cache.dir, `${report.cacheKey}.json`);
expect(existsSync(cachePath)).toBe(true);
});
it("listReports returns the cached report newest-first", () => {
const report = runPipeline({ path: toy.surface, repoRoot: toy.repoRoot }, "1 year ago");
const cache = createReportCache(defaultCacheDir(toy.repoRoot));
cache.write(report);
const list = cache.list();
expect(list.length).toBeGreaterThanOrEqual(1);
expect(list[0]?.surface).toBe(toy.surface);
});
});
@@ -0,0 +1,75 @@
/**
* Covers peak and drift detection in findInflections over hand-built health
* timelines: peak selection with a minimum threshold and sustained-drop drift
* onset. Pure, deterministic.
*/
import { describe, expect, it } from "vitest";
import { findInflections } from "../src/pipeline/inflect.ts";
import type { CommitHealthPoint } from "../src/types.ts";
function point(score: number, delta: number, sha = `${score}`): CommitHealthPoint {
return {
sha: sha.padEnd(40, "0"),
parents: [],
author: "alice",
authorEmail: "alice@example.com",
date: "2026-04-01T10:00:00Z",
subject: "x",
body: "",
files: [],
diffSnippet: "",
type: "other",
riskFlags: [],
classifiedBy: "rule",
delta,
score,
churn: 0,
};
}
describe("findInflections.peaks", () => {
it("finds a single peak in an ascending-then-descending series", () => {
const points = [
point(0.05, 0.05, "a"),
point(0.15, 0.1, "b"),
point(0.4, 0.25, "c"), // peak
point(0.25, -0.15, "d"),
point(0.1, -0.15, "e"),
point(0.05, -0.05, "f"),
];
const { peaks } = findInflections(points);
expect(peaks.length).toBeGreaterThanOrEqual(1);
expect(peaks[0]?.sha.startsWith("c")).toBe(true);
});
it("ignores peaks below the minimum threshold", () => {
const points = [point(0.0, 0), point(0.02, 0.02), point(0.01, -0.01)];
const { peaks } = findInflections(points);
expect(peaks.length).toBe(0);
});
});
describe("findInflections.drifts", () => {
it("finds a drift onset when the score drops sustainedly after a point", () => {
const points = [
point(0.2, 0.05, "a"),
point(0.3, 0.1, "b"),
point(0.5, 0.2, "c"), // drift onset
point(0.2, -0.3, "d"),
point(0.05, -0.15, "e"),
point(-0.05, -0.1, "f"),
point(-0.1, -0.05, "g"),
point(-0.1, 0.0, "h"),
];
const { drifts } = findInflections(points);
expect(drifts.length).toBeGreaterThanOrEqual(1);
expect(drifts.some((d) => d.sha.startsWith("c"))).toBe(true);
});
it("returns no drifts on flat history", () => {
const points = Array.from({ length: 8 }, (_, i) => point(0.05, 0.0, `${i}`));
const { drifts } = findInflections(points);
expect(drifts.length).toBe(0);
});
});
@@ -0,0 +1,102 @@
/**
* Covers the narrate phase: deterministic rot-cause fallback when no model is
* present, and budget-capped model narration that falls back for drifts beyond
* budget. The model is a vi.fn stub, not a live LLM.
*/
import { describe, expect, it, vi } from "vitest";
import { narrate } from "../src/pipeline/narrate.ts";
import type { CommitHealthPoint, InflectionPoint } from "../src/types.ts";
function point(
sha: string,
score: number,
delta: number,
overrides: Partial<CommitHealthPoint> = {}
): CommitHealthPoint {
return {
sha: sha.padEnd(40, "0"),
parents: [],
author: "alice",
authorEmail: "alice@example.com",
date: "2026-04-01T10:00:00Z",
subject: "change",
body: "",
files: [{ path: "src/a.ts", added: 20, deleted: 5, status: "M" }],
diffSnippet: "",
type: "other",
riskFlags: [],
classifiedBy: "rule",
delta,
score,
churn: 25,
...overrides,
};
}
function driftFor(point: CommitHealthPoint): InflectionPoint {
return {
sha: point.sha,
date: point.date,
author: point.author,
score: point.score,
delta: point.delta,
reasonShort: "score dropped",
};
}
describe("narrate", () => {
it("emits deterministic rot causes when no model is available", async () => {
const timeline = [
point("a", 0.3, 0.1),
point("b", -0.2, -0.5, {
subject: "revert broken change",
type: "revert",
}),
point("c", -0.3, -0.1),
];
const result = await narrate(null, {
surfacePath: "src/a.ts",
repoRoot: "/tmp/missing",
timeline,
drifts: [driftFor(timeline[1])],
budget: 0,
});
expect(result.llmCalls).toBe(0);
expect(result.rotCauses).toHaveLength(1);
expect(result.rotCauses[0]?.category).toBe("revert-cycle");
expect(result.rotCauses[0]?.narrative).toContain("deterministic inflection");
});
it("uses model narration only within budget and falls back for remaining drifts", async () => {
const timeline = [
point("a", 0.3, 0.1),
point("b", -0.2, -0.5, { churn: 700 }),
point("c", -0.4, -0.2, { subject: "hotfix production issue" }),
];
const runtime = {
useModel: vi.fn(async () =>
JSON.stringify({
category: "scope-creep",
narrative: "The model explains this drift with concrete evidence.",
})
),
};
const result = await narrate(runtime as never, {
surfacePath: "src/a.ts",
repoRoot: "/tmp/missing",
timeline,
drifts: [driftFor(timeline[1]), driftFor(timeline[2])],
budget: 1,
});
expect(result.llmCalls).toBe(1);
expect(runtime.useModel).toHaveBeenCalledTimes(1);
expect(result.rotCauses).toHaveLength(2);
expect(result.rotCauses[0]?.narrative).toContain("model explains");
expect(result.rotCauses[1]?.narrative).toContain("deterministic inflection");
});
});
@@ -0,0 +1,72 @@
/**
* Covers normalizeSince window translation and scan against a real toy git
* repository (built with the git binary): commit ordering, file-touch line
* counts, parents, headSha, and the empty-surface case.
*/
import { rmSync } from "node:fs";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { headSha, normalizeSince, scan } from "../src/pipeline/scan.ts";
import { buildToyRepo, type ToyRepoSpec } from "./toy-repo.ts";
describe("normalizeSince", () => {
it("translates relative windows", () => {
expect(normalizeSince("14d")).toBe("14 days ago");
expect(normalizeSince("4w")).toBe("4 weeks ago");
expect(normalizeSince("3m")).toBe("3 months ago");
expect(normalizeSince("1y")).toBe("1 years ago");
});
it("passes through ISO-ish strings unchanged", () => {
expect(normalizeSince("2026-04-01")).toBe("2026-04-01");
});
});
describe("scan (against toy repo)", () => {
let toy: ToyRepoSpec;
beforeAll(() => {
toy = buildToyRepo();
}, 30_000);
afterAll(() => {
rmSync(toy.repoRoot, { recursive: true, force: true });
});
it("returns all commits touching the surface in chronological-reverse order", () => {
const commits = scan({ path: toy.surface, repoRoot: toy.repoRoot }, { since: "1 year ago" });
const expected =
toy.commitsByPhase.A.length +
toy.commitsByPhase.B.length +
toy.commitsByPhase.C.length +
toy.commitsByPhase.D.length;
expect(commits.length).toBe(expected);
expect(commits[0]?.sha).toBe(toy.commitsByPhase.D[toy.commitsByPhase.D.length - 1]);
});
it("captures file touches with line counts", () => {
const commits = scan({ path: toy.surface, repoRoot: toy.repoRoot }, { since: "1 year ago" });
const wip = commits.find((c) => c.subject.startsWith("wip"));
expect(wip).toBeDefined();
expect(wip?.files.length).toBeGreaterThan(0);
const totalAdded = wip?.files.reduce((acc, f) => acc + f.added, 0) ?? 0;
expect(totalAdded).toBeGreaterThan(100);
});
it("captures parents", () => {
const commits = scan({ path: toy.surface, repoRoot: toy.repoRoot }, { since: "1 year ago" });
for (const commit of commits) {
expect(commit.parents.length).toBeGreaterThanOrEqual(1);
}
});
it("headSha returns 40-char sha", () => {
expect(headSha(toy.repoRoot)).toMatch(/^[0-9a-f]{40}$/);
});
it("returns empty array when surface has no commits in window", () => {
const out = scan(
{ path: "nonexistent-dir-xyz", repoRoot: toy.repoRoot },
{ since: "1 year ago" }
);
expect(out).toEqual([]);
});
});
@@ -0,0 +1,116 @@
/**
* Covers the deterministic EMA scorer: clean features outscore WIP dumps, test
* touches earn a bonus, the running average stays bounded, and a revert of a
* recent commit back-penalizes its target. Pure, no git or model.
*/
import { describe, expect, it } from "vitest";
import { classify } from "../src/pipeline/classify.ts";
import { score } from "../src/pipeline/score.ts";
import type { RawCommit } from "../src/types.ts";
function commit(
sha: string,
subject: string,
parents: string[],
files: RawCommit["files"]
): RawCommit {
return {
sha: sha.padEnd(40, "0"),
parents,
author: "alice",
authorEmail: "alice@example.com",
date: "2026-04-01T10:00:00Z",
subject,
body: "",
files,
diffSnippet: "",
};
}
describe("score", () => {
it("assigns higher delta to clean features than to wip dumps", () => {
const raw = [
commit(
"aaa1111",
"feat: add a",
[],
[{ path: "src/a.ts", added: 30, deleted: 0, status: "A" }]
),
commit(
"bbb2222",
"wip: dump",
[],
[
{ path: "src/big1.ts", added: 800, deleted: 0, status: "A" },
{ path: "src/big2.ts", added: 600, deleted: 0, status: "A" },
]
),
];
const points = score(classify(raw));
const feat = points[0];
const wip = points[1];
expect(feat).toBeDefined();
expect(wip).toBeDefined();
expect(feat?.delta).toBeGreaterThan(wip?.delta ?? 0);
});
it("rewards commits that touch tests", () => {
const withTest = score(
classify([
commit(
"aaa1111",
"refactor: clean helper",
[],
[
{ path: "src/helper.ts", added: 20, deleted: 5, status: "M" },
{ path: "src/__tests__/helper.test.ts", added: 30, deleted: 0, status: "A" },
]
),
])
);
const withoutTest = score(
classify([
commit(
"bbb2222",
"refactor: clean helper",
[],
[{ path: "src/helper.ts", added: 20, deleted: 5, status: "M" }]
),
])
);
expect(withTest[0]?.delta).toBeGreaterThan(withoutTest[0]?.delta ?? 0);
});
it("running EMA stays bounded for clean history", () => {
const raw = Array.from({ length: 10 }, (_, i) =>
commit(
`abc${i}`,
`feat: item ${i}`,
[],
[{ path: `src/f${i}.ts`, added: 20, deleted: 5, status: "A" }]
)
);
const points = score(classify(raw));
expect(points.every((p) => Math.abs(p.score) < 2)).toBe(true);
});
it("applies later-reverted penalty when a revert references a recent sha", () => {
const target = commit(
"aaa1111",
"feat: probably-bad",
[],
[{ path: "src/bad.ts", added: 50, deleted: 0, status: "A" }]
);
const revert = commit(
"bbb2222",
"revert: undo it",
[],
[{ path: "src/bad.ts", added: 0, deleted: 50, status: "D" }]
);
revert.body = `This reverts commit ${target.sha}.`;
const points = score(classify([target, revert]));
const targetPoint = points[0];
expect(targetPoint?.riskFlags).toContain("later-reverted");
});
});
@@ -0,0 +1,69 @@
/**
* Covers the secret scrubber: redaction of Anthropic, OpenAI, GitHub, AWS, and
* Bearer tokens plus env-name=value patterns, benign-text passthrough, and the
* deep object and array walk. Pure string transforms.
*/
import { describe, expect, it } from "vitest";
import { scrubSecrets, scrubSecretsDeep } from "../src/secret-scrubber.ts";
describe("scrubSecrets", () => {
it("redacts Anthropic keys", () => {
const input = "key sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAA in env";
expect(scrubSecrets(input)).toBe("key <REDACTED:ANTHROPIC> in env");
});
it("redacts OpenAI keys including project keys", () => {
expect(scrubSecrets("OPENAI_API_KEY=sk-proj-AAAAAAAAAAAAAAAAAAAAAAAA")).toContain(
"<REDACTED:OPENAI>"
);
expect(scrubSecrets("token sk-AAAAAAAAAAAAAAAAAAAAAAAA done")).toContain("<REDACTED:OPENAI>");
});
it("redacts GitHub PATs", () => {
expect(scrubSecrets("ghp_AAAAAAAAAAAAAAAAAAAAAAAA")).toContain("<REDACTED:GITHUB>");
expect(scrubSecrets("ghs_AAAAAAAAAAAAAAAAAAAAAAAA")).toContain("<REDACTED:GITHUB>");
});
it("redacts AWS access keys", () => {
expect(scrubSecrets("AKIAIOSFODNN7EXAMPLE")).toContain("<REDACTED:AWS>");
});
it("redacts Bearer headers", () => {
expect(scrubSecrets("Authorization: Bearer abcdef1234567890ABCDEF")).toContain(
"<REDACTED:BEARER>"
);
});
it("redacts env-name patterns with secret-looking values", () => {
expect(scrubSecrets('SOME_API_TOKEN="abcdefghijklmnop"')).toContain(
"SOME_API_TOKEN=<REDACTED:ENV>"
);
});
it("leaves benign text untouched", () => {
const input = "this is a normal commit message about features";
expect(scrubSecrets(input)).toBe(input);
});
it("returns empty string unchanged", () => {
expect(scrubSecrets("")).toBe("");
});
});
describe("scrubSecretsDeep", () => {
it("walks objects + arrays + strings", () => {
const result = scrubSecretsDeep({
name: "x",
values: ["plain", "sk-ant-AAAAAAAAAAAAAAAAAAAAAAAA"],
nested: { key: "Bearer abcdef1234567890ABCDEF" },
count: 5,
});
expect(result).toEqual({
name: "x",
values: ["plain", "<REDACTED:ANTHROPIC>"],
nested: { key: "<REDACTED:BEARER>" },
count: 5,
});
});
});
@@ -0,0 +1,164 @@
/**
* Build a synthetic git repository with a planted health trajectory:
* phase A (5 commits) — clean feature work, low churn, conventional messages
* phase B (1 commit) — WIP dump with huge churn → drift inflection
* phase C (4 commits) — churn-spiral: quick reverts and patches
* phase D (4 commits) — recovery: refactor + tests
*
* Each test that needs a repo calls `buildToyRepo()` and gets back a unique
* temp dir. Caller is responsible for cleanup (rmSync recursive).
*/
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
function git(repoRoot: string, args: string[]): void {
execFileSync("git", args, {
cwd: repoRoot,
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_NAME: "Toy",
GIT_AUTHOR_EMAIL: "toy@example.test",
GIT_COMMITTER_NAME: "Toy",
GIT_COMMITTER_EMAIL: "toy@example.test",
},
});
}
function commit(repoRoot: string, message: string, dateIso: string): void {
execFileSync("git", ["commit", "-m", message, `--date=${dateIso}`], {
cwd: repoRoot,
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_NAME: "Toy",
GIT_AUTHOR_EMAIL: "toy@example.test",
GIT_COMMITTER_NAME: "Toy",
GIT_COMMITTER_EMAIL: "toy@example.test",
GIT_AUTHOR_DATE: dateIso,
GIT_COMMITTER_DATE: dateIso,
},
});
}
function writeFile(repoRoot: string, rel: string, content: string): void {
const full = path.join(repoRoot, rel);
mkdirSync(path.dirname(full), { recursive: true });
writeFileSync(full, content, "utf8");
}
function smallFeatureLines(seed: number): string {
const lines: string[] = [];
for (let i = 0; i < 20; i++) lines.push(`// line ${seed}-${i} small change`);
return lines.join("\n");
}
function biggerLines(seed: number, n: number): string {
const lines: string[] = [];
for (let i = 0; i < n; i++) lines.push(`/* seed=${seed} bulk ${i} */`);
return lines.join("\n");
}
const SURFACE_DIR = "src";
export interface ToyRepoSpec {
repoRoot: string;
surface: string;
commitsByPhase: { A: string[]; B: string[]; C: string[]; D: string[] };
}
export function buildToyRepo(): ToyRepoSpec {
const repoRoot = mkdtempSync(path.join(tmpdir(), "gitpath-toy-"));
git(repoRoot, ["init", "-q", "--initial-branch=main"]);
git(repoRoot, ["config", "commit.gpgsign", "false"]);
const commitsByPhase = {
A: [] as string[],
B: [] as string[],
C: [] as string[],
D: [] as string[],
};
// Anchor (untracked surface starts empty)
writeFile(repoRoot, "README", "toy repo\n");
git(repoRoot, ["add", "README"]);
commit(repoRoot, "chore: bootstrap toy repo", "2026-04-01T10:00:00Z");
// Phase A — clean feature work
const aSubjects = [
"feat(api): add request validator",
"feat(api): add response shaper",
"fix(api): handle empty payload",
"refactor(api): extract helper",
"feat(api): paginate list endpoint",
];
for (let i = 0; i < aSubjects.length; i++) {
writeFile(repoRoot, `${SURFACE_DIR}/feature-${i}.ts`, smallFeatureLines(i));
git(repoRoot, ["add", `${SURFACE_DIR}/feature-${i}.ts`]);
commit(
repoRoot,
aSubjects[i] ?? `feat: a${i}`,
`2026-04-${String(2 + i).padStart(2, "0")}T10:00:00Z`
);
commitsByPhase.A.push(readHead(repoRoot));
}
// Phase B — single WIP dump with huge churn (drift inflection)
for (let f = 0; f < 8; f++) {
writeFile(repoRoot, `${SURFACE_DIR}/dump-${f}.ts`, biggerLines(f, 80));
}
writeFile(repoRoot, `${SURFACE_DIR}/feature-0.ts`, biggerLines(99, 60));
git(repoRoot, ["add", `${SURFACE_DIR}/`]);
commit(repoRoot, "wip: huge dump, will clean up later", "2026-04-09T10:00:00Z");
commitsByPhase.B.push(readHead(repoRoot));
// Phase C — churn spiral (reverts + tweaks)
const cSubjects = [
"fix: revert part of dump",
"fix: another patch",
"wip: still fighting fires",
"fix: more tweaks",
];
for (let i = 0; i < cSubjects.length; i++) {
for (let f = 0; f < 3; f++) {
writeFile(repoRoot, `${SURFACE_DIR}/dump-${f}.ts`, biggerLines(i * 10 + f, 50 + i * 10));
}
git(repoRoot, ["add", `${SURFACE_DIR}/`]);
commit(
repoRoot,
cSubjects[i] ?? `wip: c${i}`,
`2026-04-${String(10 + i).padStart(2, "0")}T10:00:00Z`
);
commitsByPhase.C.push(readHead(repoRoot));
}
// Phase D — recovery: refactor + tests
const dSubjects = [
"refactor(api): collapse duplicate logic",
"test(api): cover validator branches",
"refactor(api): extract pure helpers",
"test(api): add edge-case suite",
];
for (let i = 0; i < dSubjects.length; i++) {
writeFile(repoRoot, `${SURFACE_DIR}/clean-${i}.ts`, smallFeatureLines(100 + i));
writeFile(repoRoot, `${SURFACE_DIR}/__tests__/clean-${i}.test.ts`, smallFeatureLines(200 + i));
git(repoRoot, ["add", `${SURFACE_DIR}/`]);
commit(
repoRoot,
dSubjects[i] ?? `refactor: d${i}`,
`2026-04-${String(15 + i).padStart(2, "0")}T10:00:00Z`
);
commitsByPhase.D.push(readHead(repoRoot));
}
return { repoRoot, surface: SURFACE_DIR, commitsByPhase };
}
function readHead(repoRoot: string): string {
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" })
.toString()
.trim();
}
+27
View File
@@ -0,0 +1,27 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"noUnusedVariables": "error"
}
}
},
"formatter": {
"enabled": true,
"indentWidth": 2,
"indentStyle": "space",
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "es5",
"semicolons": "always"
}
}
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bun
/**
* Build entrypoint for the git-pathology plugin.
* It uses the shared plugin build driver to emit Node ESM/CJS bundles and declarations while preserving CJS sourcemap references.
*/
import { buildPlugin } from "../plugin-build";
const reexport = (from: string) => `export * from "${from}";\nexport { default } from "${from}";\n`;
await buildPlugin({
name: "@elizaos/plugin-gitpathologist",
clean: true,
externals: "auto",
externalsOptions: { extra: ["@elizaos/shared", "@elizaos/agent"] },
targets: [
{
label: "Node (ESM)",
entry: "src/index.ts",
outSubdir: "node",
target: "node",
format: "esm",
sourcemap: "external",
minify: false,
},
{
label: "Node (CJS)",
entry: "src/index.ts",
outSubdir: "cjs",
target: "node",
format: "cjs",
sourcemap: "external",
minify: false,
renames: [["index.js", "index.cjs"]],
},
],
dtsProject: "tsconfig.build.json",
dtsShims: [
{ path: "index.d.ts", content: reexport("./node/index") },
{ path: "cjs/index.d.ts", content: reexport("../node/index") },
],
});
+111
View File
@@ -0,0 +1,111 @@
{
"name": "@elizaos/plugin-gitpathologist",
"version": "2.0.3-beta.7",
"description": "Forensic git-history analysis for elizaOS agents: per-surface health timeline, drift inflection detection, rot post-mortem.",
"type": "module",
"main": "dist/node/index.js",
"module": "dist/node/index.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"repository": {
"type": "git",
"url": "git+https://github.com/elizaOS/eliza.git",
"directory": "plugins/plugin-gitpathologist"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"eliza-source": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"node": {
"types": "./dist/node/index.d.ts",
"import": "./dist/node/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/node/index.js"
},
"import": "./dist/node/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/node/index.js"
},
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"default": "./dist/*.js"
}
},
"files": [
"dist"
],
"keywords": [
"elizaos",
"elizaos-plugin",
"git",
"history",
"forensics",
"code-quality"
],
"author": "elizaOS",
"license": "MIT",
"scripts": {
"build": "bun run build.ts",
"build:ts": "bun run build.ts",
"clean": "node ../../packages/scripts/rm-path-recursive.mjs dist .turbo",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsgo --noEmit",
"lint": "bunx @biomejs/biome check --write --unsafe .",
"lint:check": "bunx @biomejs/biome check .",
"format": "bunx @biomejs/biome format --write .",
"format:check": "bunx @biomejs/biome format ."
},
"dependencies": {
"@elizaos/core": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "^25.0.3",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
},
"peerDependencies": {
"@elizaos/core": "workspace:*"
},
"publishConfig": {
"access": "public"
},
"agentConfig": {
"pluginType": "elizaos:plugin:1.0.0",
"pluginParameters": {
"ELIZA_GITPATHOLOGIST": {
"type": "boolean",
"description": "Force-enable or force-disable the gitpathologist plugin. When unset, the plugin auto-enables in workspaces with a .git directory.",
"required": false,
"sensitive": false
},
"GITPATHOLOGIST_BUDGET": {
"type": "number",
"description": "Maximum number of LLM narration calls per analysis. Caps token spend. Default 20.",
"required": false,
"sensitive": false
},
"GITPATHOLOGIST_CACHE_DIR": {
"type": "string",
"description": "Override directory for cached pathology reports. Default <workspace>/.eliza/gitpathology.",
"required": false,
"sensitive": false
}
}
},
"eliza": {
"platforms": [
"node"
],
"runtime": "node",
"platformDetails": {
"node": "Node.js build available via exports.node"
}
}
}
@@ -0,0 +1,225 @@
/**
* GIT_PATHOLOGY action — single multiplex Action that dispatches to the
* GitPathologyService. Pattern mirrors plugin-agent-orchestrator's TASKS.
*
* Actions:
* report (default) — full pathology report for a surface
* list — list cached reports for the repo root
*/
import path from "node:path";
import type {
Action,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { renderReport } from "../render.ts";
import {
GIT_PATHOLOGY_SERVICE_NAME,
type GitPathologyService,
} from "../services/git-pathology-service.ts";
import type { AnalysisOptions, Operation, PathologyReport, SurfaceSpec } from "../types.ts";
type GitPathologyOperation = Operation;
const VALID_ACTIONS: ReadonlySet<GitPathologyOperation> = new Set(["report", "list"]);
const 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;
function getService(runtime: IAgentRuntime): GitPathologyService | null {
return runtime.getService<GitPathologyService>(GIT_PATHOLOGY_SERVICE_NAME) ?? null;
}
function paramsRecord(
options: HandlerOptions | Record<string, unknown> | undefined
): Record<string, unknown> {
if (!options || typeof options !== "object") return {};
const parameters = (options as HandlerOptions).parameters;
if (parameters && typeof parameters === "object") {
return parameters as Record<string, unknown>;
}
const params = (options as Record<string, unknown>).params;
if (params && typeof params === "object") return params as Record<string, unknown>;
return options as Record<string, unknown>;
}
function readAction(params: Record<string, unknown>): GitPathologyOperation {
const rawValue = params.action;
const raw = typeof rawValue === "string" ? rawValue.toLowerCase() : "report";
return VALID_ACTIONS.has(raw as GitPathologyOperation)
? (raw as GitPathologyOperation)
: "report";
}
function readString(params: Record<string, unknown>, key: string): string | undefined {
const v = params[key];
return typeof v === "string" && v.trim() ? v.trim() : undefined;
}
function readNumber(params: Record<string, unknown>, key: string): number | undefined {
const v = params[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim()) {
const parsed = Number.parseInt(v, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function buildOptions(params: Record<string, unknown>): Partial<AnalysisOptions> {
const out: Partial<AnalysisOptions> = {};
const since = readString(params, "since");
if (since) out.since = since;
const budget = readNumber(params, "budget");
if (typeof budget === "number") out.budget = budget;
const cache = readString(params, "cache");
if (cache === "auto" || cache === "force" || cache === "read-only") out.cache = cache;
return out;
}
function resolveRepoRoot(): string {
const fromEnv = process.env.ELIZA_WORKSPACE_DIR;
const cwd = fromEnv?.trim() ? fromEnv.trim() : process.cwd();
return path.resolve(cwd);
}
function listResult(service: GitPathologyService, repoRoot: string): ActionResult {
const summaries = service.listReports(repoRoot);
if (summaries.length === 0) {
return {
success: true,
text: "No cached pathology reports for this repo yet.",
data: { reports: [] },
};
}
const lines = summaries.map(
(s) =>
`- ${s.surface} (${s.commitCount} commits) — HEAD ${s.headSha.slice(0, 7)}, generated ${s.generatedAt}`
);
return {
success: true,
text: `Cached pathology reports:\n${lines.join("\n")}`,
data: { reports: summaries },
};
}
function reportResult(report: PathologyReport): ActionResult {
return {
success: true,
text: renderReport(report),
data: { report },
};
}
export const gitPathologyAction: Action & { suppressPostActionContinuation: true } = {
name: "GIT_PATHOLOGY",
similes: [
"ANALYZE_GIT_PATHOLOGY",
"GIT_HEALTH",
"GIT_FORENSICS",
"PATHOLOGY_REPORT",
"CODE_HISTORY_HEALTH",
"WHERE_DID_ROT_START",
],
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).",
contexts: ["code", "git", "general"],
suppressPostActionContinuation: true,
parameters: [
{
name: "action",
description: "Which gitpathologist action: report or list. Default: report.",
required: false,
schema: { type: "string" as const, enum: ["report", "list"] },
},
{
name: "surface",
description: "Path or glob to analyze (relative to repo root). Required for action=report.",
required: false,
schema: { type: "string" as const },
},
{
name: "since",
description: "Lookback window. ISO date or relative (e.g. '14d', '4w'). Default '14d'.",
required: false,
schema: { type: "string" as const },
},
{
name: "budget",
description: "Max LLM narration calls per analysis. Default 20.",
required: false,
schema: { type: "integer" as const, minimum: 0 },
},
{
name: "cache",
description: "Cache policy: auto (default), force (recompute), read-only (fail on miss).",
required: false,
schema: { type: "string" as const, enum: ["auto", "force", "read-only"] },
},
],
validate: async (runtime: IAgentRuntime, message: Memory) => {
if (!getService(runtime)) return false;
const content = message.content as { text?: unknown; params?: unknown };
const params =
content.params && typeof content.params === "object"
? (content.params as Record<string, unknown>)
: null;
if (params && typeof params.action === "string") {
return true;
}
if (params && typeof params.surface === "string") return true;
const text = typeof content.text === "string" ? content.text : "";
return SURFACE_HINT_RE.test(text);
},
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> => {
const service = getService(runtime);
if (!service) {
const text = "GitPathologyService not registered on runtime.";
return { success: false, text, error: "SERVICE_UNAVAILABLE" };
}
const params = paramsRecord(options);
const action = readAction(params);
const repoRoot = resolveRepoRoot();
if (action === "list") {
const result = listResult(service, repoRoot);
if (callback && typeof result.text === "string") {
await callback({ text: result.text });
}
return result;
}
const surfacePath = readString(params, "surface");
if (!surfacePath) {
const text = "action=report requires a `surface` param (path or glob relative to repo root).";
if (callback) await callback({ text });
return { success: false, text, error: "MISSING_SURFACE" };
}
const surface: SurfaceSpec = { path: surfacePath, repoRoot };
const overrides = buildOptions(params);
let report: PathologyReport;
try {
report = await service.runReport(surface, overrides);
} catch (err) {
const text = `Git pathology analysis failed: ${(err as Error).message}`;
if (callback) await callback({ text });
return { success: false, text, error: "ANALYSIS_FAILED" };
}
const result = reportResult(report);
if (callback && typeof result.text === "string") {
await callback({ text: result.text });
}
return result;
},
};
+105
View File
@@ -0,0 +1,105 @@
/**
* On-disk cache for {@link PathologyReport}s.
*
* Layout: `<cacheDir>/<sha256(surface + since)>.json`.
*
* The cache stores the HEAD sha at analysis time. A subsequent run with a
* different HEAD is treated as a miss — incremental splice is a v2 goal, not
* needed for the first-pass "fast repeat call" win.
*/
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import type { CachedReportSummary, PathologyReport } from "../types.ts";
export interface CacheKeyInput {
surface: string;
since: string;
}
export function makeCacheKey(input: CacheKeyInput): string {
return createHash("sha256").update(`${input.surface}\0${input.since}`).digest("hex");
}
export function defaultCacheDir(repoRoot: string): string {
const override = process.env.GITPATHOLOGIST_CACHE_DIR?.trim();
if (override) {
return path.isAbsolute(override) ? override : path.join(repoRoot, override);
}
return path.join(repoRoot, ".eliza", "gitpathology");
}
export function ensureCacheDir(cacheDir: string): void {
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
}
export interface ReportCache {
readonly dir: string;
read(key: string): PathologyReport | null;
write(report: PathologyReport): void;
list(): CachedReportSummary[];
isFreshFor(key: string, currentHeadSha: string): boolean;
}
export function createReportCache(cacheDir: string): ReportCache {
ensureCacheDir(cacheDir);
const pathFor = (key: string) => path.join(cacheDir, `${key}.json`);
return {
dir: cacheDir,
read(key) {
const file = pathFor(key);
if (!existsSync(file)) return null;
try {
const raw = readFileSync(file, "utf8");
return JSON.parse(raw) as PathologyReport;
} catch {
return null;
}
},
write(report) {
const file = pathFor(report.cacheKey);
const tmp = path.join(cacheDir, `.${report.cacheKey}.${process.pid}.${Date.now()}.tmp`);
writeFileSync(tmp, JSON.stringify(report, null, 2), "utf8");
renameSync(tmp, file);
},
list() {
if (!existsSync(cacheDir)) return [];
const files = readdirSync(cacheDir).filter((f) => f.endsWith(".json"));
const out: CachedReportSummary[] = [];
for (const file of files) {
try {
const full = path.join(cacheDir, file);
const stat = statSync(full);
const raw = readFileSync(full, "utf8");
const report = JSON.parse(raw) as PathologyReport;
out.push({
cacheKey: report.cacheKey,
surface: report.surface,
generatedAt: report.generatedAt,
headSha: report.headSha,
commitCount: report.commitCount,
sizeBytes: stat.size,
});
} catch {
// error-policy:J3 untrusted-input sanitizing — the cache dir is a
// regenerable, HEAD-keyed scratch store; a truncated/corrupt entry is
// skipped from the listing rather than failing the whole `list()`.
}
}
return out.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
},
isFreshFor(key, currentHeadSha) {
const cached = this.read(key);
return cached?.headSha === currentHeadSha;
},
};
}
@@ -0,0 +1,45 @@
/**
* @elizaos/plugin-gitpathologist
*
* Forensic git-history analysis. See README and the GIT_PATHOLOGY action for
* usage. The plugin auto-detects whether the workspace is a git repository
* and only registers its action when the GitPathologyService starts.
*/
import { type IAgentRuntime, logger, type Plugin } from "@elizaos/core";
import { gitPathologyAction } from "./actions/git-pathology.ts";
import {
GIT_PATHOLOGY_SERVICE_NAME,
GitPathologyService,
} from "./services/git-pathology-service.ts";
const gitpathologistPlugin: Plugin = {
name: "@elizaos/plugin-gitpathologist",
description:
"Forensic git-history analysis for elizaOS agents: per-surface health timeline, drift inflection detection, rot post-mortem.",
init: async (_config: Record<string, string>, _runtime: IAgentRuntime): Promise<void> => {
logger.info("[GitPathology] plugin initialized");
},
async dispose(runtime: IAgentRuntime) {
const svc = runtime.getService<GitPathologyService>(GIT_PATHOLOGY_SERVICE_NAME);
await svc?.stop();
},
services: [GitPathologyService],
actions: [gitPathologyAction],
providers: [],
};
export default gitpathologistPlugin;
export { gitPathologyAction } from "./actions/git-pathology.ts";
export {
GIT_PATHOLOGY_SERVICE_NAME,
GitPathologyService,
} from "./services/git-pathology-service.ts";
export type {
AnalysisOptions,
CommitHealthPoint,
InflectionPoint,
PathologyReport,
RotCause,
SurfaceSpec,
} from "./types.ts";
@@ -0,0 +1,81 @@
/**
* Step 2 — classify: per-commit triage.
*
* Rules-first (cheap, deterministic). Anything matching a conventional commit
* prefix or obvious revert/merge/WIP signal is classified directly. The
* remainder are marked "other" by rules. Optional LLM batching can refine
* those if a model is available; it stays out of the default path to avoid LLM coupling here.
* Score+inflect work fine with "other".
*/
import type { ClassifiedCommit, CommitType, RawCommit } from "../types.ts";
const PREFIX_MAP: Record<string, CommitType> = {
feat: "feature",
feature: "feature",
fix: "fix",
bug: "fix",
hotfix: "fix",
refactor: "refactor",
perf: "refactor",
revert: "revert",
chore: "chore",
docs: "chore",
doc: "chore",
style: "chore",
test: "chore",
ci: "chore",
build: "chore",
};
const CONVENTIONAL_RE = /^([a-z]+)(?:\(([^)]+)\))?!?:\s*(.+)$/i;
const WIP_RE = /^(?:wip|fixup!|squash!|amend!)\b/i;
const REVERT_SUBJECT_RE = /^Revert\b/;
const MERGE_SUBJECT_RE = /^Merge\b/;
export function classifyOne(commit: RawCommit): ClassifiedCommit {
const subject = commit.subject.trim();
const riskFlags: string[] = [];
let type: CommitType = "other";
let scope: string | undefined;
if (commit.parents.length > 1) {
type = "merge";
} else if (MERGE_SUBJECT_RE.test(subject)) {
type = "merge";
} else if (REVERT_SUBJECT_RE.test(subject)) {
type = "revert";
riskFlags.push("revert-subject");
} else if (WIP_RE.test(subject)) {
type = "wip";
riskFlags.push("wip-message");
} else {
const match = subject.match(CONVENTIONAL_RE);
if (match) {
const prefix = (match[1] ?? "").toLowerCase();
const mapped = PREFIX_MAP[prefix];
if (mapped) {
type = mapped;
scope = match[2];
}
}
}
const churn = commit.files.reduce((acc, f) => acc + f.added + f.deleted, 0);
if (churn >= 500) riskFlags.push("large-churn");
if (commit.files.length >= 20) riskFlags.push("wide-blast");
if (subject.length < 12 && type === "other") riskFlags.push("terse-message");
if (subject.includes("!:")) riskFlags.push("breaking");
return {
...commit,
type,
scope,
riskFlags,
classifiedBy: "rule",
};
}
export function classify(commits: RawCommit[]): ClassifiedCommit[] {
return commits.map(classifyOne);
}
@@ -0,0 +1,113 @@
/**
* Step 4 — inflect: find peaks and drift onsets in the health timeline.
*
* Peak = local maximum of the EMA score over a small window. We require the
* point to dominate its neighbours strictly on both sides and to be at least
* PEAK_MIN above zero — otherwise everything is a "peak" in flat early history.
*
* Drift onset = a commit i where the average score over the next WINDOW
* commits is at least DRIFT_DROP below the score at i. The point itself is
* the inflection; subsequent commits realize the decline.
*
* Both lists are sorted by absolute significance and capped.
*/
import type { CommitHealthPoint, InflectionPoint } from "../types.ts";
const PEAK_WINDOW = 2;
const PEAK_MIN_SCORE = 0.05;
const PEAK_LIMIT = 5;
const DRIFT_WINDOW = 5;
const DRIFT_DROP = 0.25;
const DRIFT_LIMIT = 5;
function toInflection(point: CommitHealthPoint, reason: string): InflectionPoint {
return {
sha: point.sha,
date: point.date,
author: point.author,
score: point.score,
delta: point.delta,
reasonShort: reason,
};
}
function reasonForPeak(point: CommitHealthPoint): string {
const parts: string[] = [];
if (point.type === "feature") parts.push("feature landed");
else if (point.type === "refactor") parts.push("clean refactor");
else if (point.type === "fix") parts.push("targeted fix");
else parts.push(`${point.type} commit`);
if (point.riskFlags.includes("large-churn") === false && point.churn < 200) {
parts.push("low churn");
}
if (point.delta > 0.4) parts.push("strong delta");
return parts.join(", ") || "local maximum";
}
function reasonForDrift(point: CommitHealthPoint, avgAfter: number): string {
const drop = (point.score - avgAfter).toFixed(2);
const flags = point.riskFlags.length > 0 ? ` flags=${point.riskFlags.join("|")}` : "";
return `score drops ${drop} over next ${DRIFT_WINDOW} commits${flags}`;
}
function avg(points: CommitHealthPoint[], from: number, count: number): number {
let sum = 0;
let taken = 0;
for (let i = from; i < points.length && taken < count; i++) {
const p = points[i];
if (!p) continue;
sum += p.score;
taken += 1;
}
return taken === 0 ? 0 : sum / taken;
}
export function findInflections(points: CommitHealthPoint[]): {
peaks: InflectionPoint[];
drifts: InflectionPoint[];
} {
const peaks: InflectionPoint[] = [];
const drifts: InflectionPoint[] = [];
for (let i = 0; i < points.length; i++) {
const point = points[i];
if (!point) continue;
if (point.score < PEAK_MIN_SCORE) continue;
const window = points.slice(Math.max(0, i - PEAK_WINDOW), i + PEAK_WINDOW + 1);
// Peak = max-in-window with at least one strictly lower neighbour. On
// plateaus we keep the first occurrence: subsequent identical scores
// are rejected because they tie with the earlier (already-picked) peak.
const isMaxInWindow = window.every((p) => p.score <= point.score);
const hasStrictlyLessNeighbour = window.some((p) => p.score < point.score);
if (!isMaxInWindow || !hasStrictlyLessNeighbour) continue;
// Reject a tied score within the same window — we already picked an
// earlier peak with the same score, so this one would be a duplicate.
const tiedEarlier = peaks.some((existing) => {
const prevIdx = points.findIndex((p) => p.sha === existing.sha);
return prevIdx >= 0 && prevIdx >= i - PEAK_WINDOW && existing.score === point.score;
});
if (tiedEarlier) continue;
peaks.push(toInflection(point, reasonForPeak(point)));
}
for (let i = 0; i < points.length; i++) {
const point = points[i];
if (!point) continue;
if (i + DRIFT_WINDOW >= points.length) break;
const after = avg(points, i + 1, DRIFT_WINDOW);
const drop = point.score - after;
if (drop >= DRIFT_DROP) {
drifts.push(toInflection(point, reasonForDrift(point, after)));
}
}
peaks.sort((a, b) => b.score - a.score);
drifts.sort((a, b) => b.score - b.delta - (a.score - a.delta));
return {
peaks: peaks.slice(0, PEAK_LIMIT),
drifts: drifts.slice(0, DRIFT_LIMIT),
};
}
@@ -0,0 +1,210 @@
/**
* Step 5 — narrate: LLM post-mortem for drift inflections.
*
* One LLM call per drift, capped by `budget`.
*
* All commit text + diff snippets pass through {@link scrubSecrets} before
* leaving the process.
*/
import { type IAgentRuntime, logger, ModelType } from "@elizaos/core";
import { scrubSecrets } from "../secret-scrubber.ts";
import type { CommitHealthPoint, InflectionPoint, RotCategory, RotCause } from "../types.ts";
import { fetchDiffSnippet } from "./scan.ts";
const LOG_PREFIX = "[GitPathology/narrate]";
const VALID_CATEGORIES: ReadonlySet<RotCategory> = new Set([
"rushed-fix",
"scope-creep",
"bad-merge",
"revert-cycle",
"churn-spiral",
"other",
]);
export interface NarrateContext {
surfacePath: string;
repoRoot: string;
timeline: CommitHealthPoint[];
drifts: InflectionPoint[];
budget: number;
}
interface UseModelLike {
useModel?: (modelType: string, options: Record<string, unknown>) => Promise<unknown>;
}
export async function narrate(
runtime: IAgentRuntime | null,
ctx: NarrateContext
): Promise<{ rotCauses: RotCause[]; llmCalls: number }> {
const rotCauses: RotCause[] = [];
let llmCalls = 0;
const indexBySha = new Map<string, number>(ctx.timeline.map((point, idx) => [point.sha, idx]));
const useModelFn = (runtime as UseModelLike | null)?.useModel;
const budget = Math.max(0, Math.floor(ctx.budget));
for (const drift of ctx.drifts) {
const idx = indexBySha.get(drift.sha);
if (idx === undefined) continue;
const point = ctx.timeline[idx];
if (!point) continue;
const before = ctx.timeline.slice(Math.max(0, idx - 3), idx);
const after = ctx.timeline.slice(idx + 1, idx + 4);
const fallback = deterministicRotCause(point, before, after, drift);
if (typeof useModelFn === "function" && llmCalls < budget) {
const diff = scrubSecrets(
fetchDiffSnippet(ctx.repoRoot, point.sha, ctx.surfacePath, 8 * 1024)
);
try {
const result = await callModel(
useModelFn,
buildPrompt(ctx.surfacePath, point, before, after, diff)
);
llmCalls += 1;
const parsed = parseRotCause(result);
if (parsed) {
rotCauses.push({
...fallback,
category: parsed.category,
narrative: parsed.narrative,
});
continue;
}
logger.warn(`${LOG_PREFIX} model returned unparseable rot cause for ${point.sha}`);
} catch (err) {
logger.warn(`${LOG_PREFIX} model call failed for ${point.sha}: ${(err as Error).message}`);
}
} else if (typeof useModelFn !== "function" && llmCalls === 0 && rotCauses.length === 0) {
logger.warn(`${LOG_PREFIX} runtime has no useModel; using deterministic rot-cause fallback`);
}
rotCauses.push(fallback);
}
return { rotCauses, llmCalls };
}
function deterministicRotCause(
point: CommitHealthPoint,
before: CommitHealthPoint[],
after: CommitHealthPoint[],
drift: InflectionPoint
): RotCause {
const category = categorizeDeterministically(point, before, after);
const flags = point.riskFlags.length > 0 ? point.riskFlags.join(", ") : "no explicit flags";
const previousScore = before.at(-1)?.score;
const nextScore = after.at(-1)?.score;
const narrative = [
`${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}.`,
`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.`,
].join(" ");
return {
shaRange: rangeFor(point, after),
category,
evidence: evidenceShas(point, before, after),
narrative,
};
}
function categorizeDeterministically(
point: CommitHealthPoint,
before: CommitHealthPoint[],
after: CommitHealthPoint[]
): RotCategory {
const subject = point.subject.toLowerCase();
const flags = new Set(point.riskFlags.map((flag) => flag.toLowerCase()));
if (point.type === "revert" || subject.includes("revert")) return "revert-cycle";
if (point.type === "merge" || flags.has("merge")) return "bad-merge";
if (flags.has("hotfix") || subject.includes("hotfix") || subject.includes("quick fix")) {
return "rushed-fix";
}
const neighboringChurn = [...before, ...after].reduce((sum, commit) => sum + commit.churn, 0);
if (point.churn > 500 || neighboringChurn > 1000) return "churn-spiral";
if (point.files.length > 8 || flags.has("large-change")) return "scope-creep";
return "other";
}
function rangeFor(point: CommitHealthPoint, after: CommitHealthPoint[]): [string, string] {
const last = after.length > 0 ? after[after.length - 1] : null;
return [point.sha, last ? last.sha : point.sha];
}
function evidenceShas(
point: CommitHealthPoint,
before: CommitHealthPoint[],
after: CommitHealthPoint[]
): string[] {
return [...before.map((p) => p.sha), point.sha, ...after.map((p) => p.sha)];
}
function buildPrompt(
surface: string,
point: CommitHealthPoint,
before: CommitHealthPoint[],
after: CommitHealthPoint[],
diff: string
): string {
const fmt = (p: CommitHealthPoint) =>
` ${p.sha.slice(0, 7)} [${p.type}] (${p.churn} churn, score ${p.score.toFixed(2)}) ${scrubSecrets(p.subject)}`;
return [
"You are diagnosing the start of a code-quality decline in a git repository surface.",
"",
`Surface: ${surface}`,
`Drift commit: ${point.sha.slice(0, 7)} by ${point.author} on ${point.date.slice(0, 10)}`,
` Subject: ${scrubSecrets(point.subject)}`,
` Type: ${point.type} Risk flags: ${point.riskFlags.join(", ") || "(none)"} Churn: ${point.churn} Files: ${point.files.length}`,
` Score: ${point.score.toFixed(2)} Delta: ${point.delta.toFixed(2)}`,
"",
"Commits immediately before (oldest first):",
before.length === 0 ? " (none in window)" : before.map(fmt).join("\n"),
"",
"Commits immediately after (oldest first):",
after.length === 0 ? " (none in window)" : after.map(fmt).join("\n"),
"",
"Diff snippet of the drift commit (secrets redacted):",
diff || " (no diff available)",
"",
"Classify the most likely cause from this set:",
' "rushed-fix", "scope-creep", "bad-merge", "revert-cycle", "churn-spiral", "other"',
"",
"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.",
"",
'Respond with exactly one JSON object: {"category": "<one of the above>", "narrative": "<2-3 sentences>"}',
].join("\n");
}
async function callModel(
useModelFn: NonNullable<UseModelLike["useModel"]>,
prompt: string
): Promise<string> {
const result = await useModelFn(ModelType.TEXT_SMALL, {
prompt,
temperature: 0.2,
stream: false,
});
if (typeof result !== "string") return "";
return result;
}
function parseRotCause(raw: string): { category: RotCategory; narrative: string } | null {
if (!raw) return null;
const jsonStart = raw.indexOf("{");
const jsonEnd = raw.lastIndexOf("}");
if (jsonStart < 0 || jsonEnd <= jsonStart) return null;
const slice = raw.slice(jsonStart, jsonEnd + 1);
try {
const obj = JSON.parse(slice) as { category?: unknown; narrative?: unknown };
const category = typeof obj.category === "string" ? obj.category : "other";
const narrative = typeof obj.narrative === "string" ? obj.narrative.trim() : "";
if (!narrative) return null;
const safeCategory = VALID_CATEGORIES.has(category as RotCategory)
? (category as RotCategory)
: "other";
return { category: safeCategory, narrative };
} catch {
return null;
}
}
@@ -0,0 +1,170 @@
/**
* Step 1 — scan: deterministic git log parsing.
*
* Calls `git log` once with a NUL-separated custom format and `--name-status
* --numstat` for file-level change info. No LLM. Diff snippets are NOT
* captured here — narrate() pulls those per-drift via `git show` to keep
* scan cheap.
*/
import { spawnSync } from "node:child_process";
import path from "node:path";
import type { FileTouch, RawCommit, SurfaceSpec } from "../types.ts";
const RECORD_SEP = "\x1e";
const UNIT_SEP = "\x1f";
// Format: <RS>COMMIT<US>sha<US>parents<US>author<US>email<US>date<US>subject<US>body
// Git appends `--name-status --numstat` lines AFTER the body, inside the same
// RS-delimited record. The next commit begins with its own leading RS — so we
// MUST NOT add a trailing RS here, or the file-stats become orphan records.
const LOG_FORMAT = `${[`${RECORD_SEP}COMMIT`, "%H", "%P", "%an", "%ae", "%aI", "%s"].join(
UNIT_SEP
)}${UNIT_SEP}%b`;
export interface ScanOptions {
since: string;
}
export function resolveSurfacePath(surface: SurfaceSpec): string {
if (path.isAbsolute(surface.path)) {
return path.relative(surface.repoRoot, surface.path) || ".";
}
return surface.path;
}
export function runGit(repoRoot: string, args: string[]): string {
const result = spawnSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
// spawnSync sets `result.error` on ENOENT (git binary missing). The status
// is null in that case — checking status alone gives an opaque error.
if (result.error || result.status !== 0) {
const detail = result.error?.message ?? result.stderr?.toString() ?? "";
throw new Error(`git ${args.join(" ")} failed: ${detail}`);
}
return result.stdout.toString();
}
export function headSha(repoRoot: string): string {
return runGit(repoRoot, ["rev-parse", "HEAD"]).trim();
}
function inferStatus(added: number, deleted: number): FileTouch["status"] {
if (added > 0 && deleted === 0) return "A";
if (added === 0 && deleted > 0) return "D";
return "M";
}
function parseFileBlock(lines: string[]): FileTouch[] {
// git log --numstat emits one line per file: "<added>\t<deleted>\t<path>".
// For binary files, added and deleted are "-". Status (A/M/D) is inferred
// from the counts — coarse but sufficient for the scoring formula, which
// only consumes churn and file count, not the precise A/M/D distinction.
const touches: FileTouch[] = [];
for (const line of lines) {
if (!line) continue;
const parts = line.split("\t");
if (parts.length < 3) continue;
const [addedRaw, deletedRaw, ...pathParts] = parts;
const addedStr = addedRaw ?? "0";
const deletedStr = deletedRaw ?? "0";
const added = addedStr === "-" ? 0 : Number.parseInt(addedStr, 10);
const deleted = deletedStr === "-" ? 0 : Number.parseInt(deletedStr, 10);
if (!Number.isFinite(added) || !Number.isFinite(deleted)) continue;
const filePath = pathParts.join("\t");
if (!filePath) continue;
touches.push({ path: filePath, added, deleted, status: inferStatus(added, deleted) });
}
return touches;
}
export function scan(surface: SurfaceSpec, options: ScanOptions): RawCommit[] {
const surfacePath = resolveSurfacePath(surface);
const args = [
"log",
"--no-color",
`--since=${normalizeSince(options.since)}`,
`--pretty=format:${LOG_FORMAT}`,
"--numstat",
"--no-renames",
"--",
surfacePath,
];
const raw = runGit(surface.repoRoot, args);
if (!raw.trim()) return [];
const records = raw.split(RECORD_SEP).filter((r) => r.trim());
const commits: RawCommit[] = [];
for (const record of records) {
if (!record.startsWith("COMMIT")) continue;
const rest = record.slice("COMMIT".length);
const fields = rest.split(UNIT_SEP);
if (fields.length < 8) continue;
const [, sha, parentsStr, author, authorEmail, date, subject, bodyAndFiles] = fields;
if (!sha || !date) continue;
const lines = (bodyAndFiles ?? "").split("\n");
const splitIdx = findFileBlockStart(lines);
const body = lines.slice(0, splitIdx).join("\n").trim();
const fileLines = lines.slice(splitIdx).filter((l) => l.length > 0);
const files = parseFileBlock(fileLines);
commits.push({
sha,
parents: parentsStr ? parentsStr.split(" ").filter(Boolean) : [],
author: author ?? "",
authorEmail: authorEmail ?? "",
date,
subject: subject ?? "",
body,
files,
diffSnippet: "",
});
}
return commits;
}
function findFileBlockStart(lines: string[]): number {
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (!line) continue;
const first = line.split("\t")[0] ?? "";
// --numstat lines start with a number (or "-" for binary).
if (/^\d+$/.test(first) || first === "-") return i;
}
return lines.length;
}
const RELATIVE_SINCE = /^(\d+)\s*(d|w|m|y)$/;
export function normalizeSince(since: string): string {
const match = since.trim().match(RELATIVE_SINCE);
if (!match) return since;
const n = match[1] ?? "0";
const unit = match[2] ?? "d";
const map: Record<string, string> = {
d: "days",
w: "weeks",
m: "months",
y: "years",
};
return `${n} ${map[unit] ?? "days"} ago`;
}
export function fetchDiffSnippet(
repoRoot: string,
sha: string,
surfacePath: string,
maxBytes = 16 * 1024
): string {
const args = ["show", "--no-color", "-U2", "--format=", sha, "--", surfacePath];
const result = spawnSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
});
if (result.status !== 0) return "";
const out = result.stdout.toString();
return out.length > maxBytes ? `${out.slice(0, maxBytes)}\n... [truncated]` : out;
}
@@ -0,0 +1,124 @@
/**
* Step 3 — score: per-commit health delta + running EMA.
*
* Deterministic. The goal is a stable, explainable score that a human can
* sanity-check against the actual history. No LLM.
*
* Formula sketch (all components small, bounded):
* delta = baseScore(type)
* - churnPenalty (log10 of total lines, capped)
* - filesPenalty (5% per file, capped at 0.5)
* - wipPenalty (-0.3 if subject signals WIP/fixup)
* - revertProximityPenalty (-0.5 on the commit that gets reverted within window)
* + testBonus (+0.2 if any file touches a test path)
*
* EMA: score_i = alpha * delta_i + (1 - alpha) * score_{i-1}, alpha = 0.3.
*
* The timeline is processed CHRONOLOGICALLY (oldest first). `git log` returns
* newest-first; the caller reverses before scoring.
*/
import type { ClassifiedCommit, CommitHealthPoint, CommitType } from "../types.ts";
const ALPHA = 0.3;
const REVERT_LOOKBACK = 7;
const BASE: Record<CommitType, number> = {
feature: 0.5,
refactor: 0.4,
fix: 0.2,
chore: 0.1,
merge: 0.0,
other: 0.0,
wip: -0.3,
revert: -0.5,
};
const TEST_PATH_RE = /(?:^|\/)(__tests__|tests?)\/|\.(?:test|spec)\.[jt]sx?$/i;
const REVERT_SHA_RE = /\b([0-9a-f]{7,40})\b/i;
/**
* Free under 100 lines (normal commit size), log-scaled penalty above that.
* Examples:
* 100 lines → 0 500 lines → 0.28
* 1000 lines → 0.40 10000 lines → 0.80
* Capped at 1.2 so a single catastrophic commit cannot overwhelm the EMA.
*/
function churnPenalty(churn: number): number {
if (churn <= 100) return 0;
const value = Math.log10(churn / 100) * 0.4;
return Math.min(value, 1.2);
}
function filesPenalty(count: number): number {
return Math.min(count * 0.05, 0.5);
}
function testBonus(commit: ClassifiedCommit): number {
return commit.files.some((f) => TEST_PATH_RE.test(f.path)) ? 0.2 : 0;
}
function wipPenalty(commit: ClassifiedCommit): number {
return commit.riskFlags.includes("wip-message") ? 0.3 : 0;
}
function findRevertTarget(
commit: ClassifiedCommit,
byShortSha: Map<string, number>
): number | null {
if (commit.type !== "revert") return null;
const match = commit.body.match(REVERT_SHA_RE) ?? commit.subject.match(REVERT_SHA_RE);
if (!match) return null;
const sha = (match[1] ?? "").toLowerCase();
const short = sha.slice(0, 7);
const idx = byShortSha.get(short);
return typeof idx === "number" ? idx : null;
}
export function score(commits: ClassifiedCommit[]): CommitHealthPoint[] {
const points: CommitHealthPoint[] = [];
const byShortSha = new Map<string, number>();
let running = 0;
for (let i = 0; i < commits.length; i++) {
const commit = commits[i];
if (!commit) continue;
const churn = commit.files.reduce((acc, f) => acc + f.added + f.deleted, 0);
const base = BASE[commit.type];
const delta =
base -
churnPenalty(churn) -
filesPenalty(commit.files.length) -
wipPenalty(commit) +
testBonus(commit);
running = ALPHA * delta + (1 - ALPHA) * running;
points.push({ ...commit, delta, score: running, churn });
byShortSha.set(commit.sha.slice(0, 7), i);
}
// Apply revert-proximity penalty by mutating the reverted commit's score and
// re-rolling the EMA forward from that point.
let needsRecompute = false;
for (let i = 0; i < points.length; i++) {
const point = points[i];
if (!point) continue;
const targetIdx = findRevertTarget(point, byShortSha);
if (targetIdx === null) continue;
const distance = i - targetIdx;
if (distance <= 0 || distance > REVERT_LOOKBACK) continue;
const target = points[targetIdx];
if (!target) continue;
target.delta -= 0.5;
target.riskFlags = [...target.riskFlags, "later-reverted"];
needsRecompute = true;
}
if (needsRecompute) {
running = 0;
for (const point of points) {
running = ALPHA * point.delta + (1 - ALPHA) * running;
point.score = running;
}
}
return points;
}
@@ -0,0 +1,60 @@
/**
* Renders a PathologyReport into the Markdown the GIT_PATHOLOGY action returns:
* a header block, the peak and drift inflection lists, and the rot post-mortem.
*/
import type { InflectionPoint, PathologyReport, RotCause } from "./types.ts";
function short(sha: string): string {
return sha.slice(0, 7);
}
function inflectionLine(point: InflectionPoint): string {
const direction = point.delta >= 0 ? "+" : "";
return `- \`${short(point.sha)}\` (${point.date.slice(0, 10)}, ${point.author}): score ${point.score.toFixed(2)} ${direction}${point.delta.toFixed(2)}${point.reasonShort}`;
}
function rotCauseBlock(cause: RotCause): string {
const [from, to] = cause.shaRange;
const evidence =
cause.evidence.length > 0 ? `\n - Evidence: ${cause.evidence.map(short).join(", ")}` : "";
return `### ${cause.category}\`${short(from)}\`..\`${short(to)}\`\n\n${cause.narrative}${evidence}`;
}
export function renderReport(report: PathologyReport): string {
const window = `${report.window.since.slice(0, 10)}${report.window.until.slice(0, 10)}`;
const peaks =
report.peaks.length === 0
? "_None detected in window._"
: report.peaks.map(inflectionLine).join("\n");
const drifts =
report.drifts.length === 0
? "_None detected in window._"
: report.drifts.map(inflectionLine).join("\n");
const causes =
report.rotCauses.length === 0
? "_No drift narration generated. Either no drifts detected, or budget = 0._"
: report.rotCauses.map(rotCauseBlock).join("\n\n");
const authors = report.authors.length > 0 ? report.authors.join(", ") : "_none_";
return `# Git Pathology — \`${report.surface}\`
**Repo:** \`${report.repoRoot}\`
**Window:** ${window}
**HEAD:** \`${short(report.headSha)}\`
**Commits analyzed:** ${report.commitCount} (${authors})
**LLM calls:** ${report.llmCalls}
## Peaks (local maxima of health)
${peaks}
## Drift inflections (sustained downturns)
${drifts}
## Rot post-mortem
${causes}
`;
}
@@ -0,0 +1,49 @@
/**
* Local secret scrubber for raw text (commit messages, diff snippets).
*
* Kept independent of plugin-training's privacy-filter — that filter operates
* on trajectory objects, not raw strings, and pulling it in would create a
* cross-plugin dependency for one regex pass.
*
* Patterns aligned with plugin-training/privacy-filter:
* - Anthropic / OpenAI style keys (sk-ant-..., sk-...)
* - GitHub PATs (ghp_, gho_, ghu_, ghs_, ghr_)
* - AWS access keys (AKIA...)
* - Bearer tokens in headers
* - Long opaque hex/base64 chunks that follow known secret-y env names
*/
const SECRET_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
{ label: "ANTHROPIC", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
{ label: "OPENAI", pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
{ label: "GITHUB", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
{ label: "AWS", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
{ label: "SLACK", pattern: /\bxox[bpoa]-[A-Za-z0-9-]{10,}\b/g },
{ label: "BEARER", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/g },
];
const 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;
export function scrubSecrets(input: string): string {
if (!input) return input;
let out = input;
for (const { label, pattern } of SECRET_PATTERNS) {
out = out.replace(pattern, `<REDACTED:${label}>`);
}
out = out.replace(SECRET_ENV_NAME, (_match, name: string) => `${name}=<REDACTED:ENV>`);
return out;
}
export function scrubSecretsDeep<T>(value: T): T {
if (typeof value === "string") return scrubSecrets(value) as T;
if (Array.isArray(value)) return value.map(scrubSecretsDeep) as T;
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = scrubSecretsDeep(v);
}
return out as T;
}
return value;
}
@@ -0,0 +1,151 @@
/**
* GitPathologyService — orchestrates the gitpathologist pipeline.
*
* One service per agent runtime. Actions call {@link runReport}; the service
* handles cache check, scan → classify → score → inflect → narrate, and cache
* write. No internal background work; pure on-demand.
*/
import { type IAgentRuntime, logger, Service } from "@elizaos/core";
import { createReportCache, defaultCacheDir, makeCacheKey } from "../cache/report-cache.ts";
import { classify } from "../pipeline/classify.ts";
import { findInflections } from "../pipeline/inflect.ts";
import { narrate } from "../pipeline/narrate.ts";
import { headSha as readHeadSha, resolveSurfacePath, scan } from "../pipeline/scan.ts";
import { score } from "../pipeline/score.ts";
import { scrubSecretsDeep } from "../secret-scrubber.ts";
import type {
AnalysisOptions,
CachedReportSummary,
PathologyReport,
SurfaceSpec,
} from "../types.ts";
export const GIT_PATHOLOGY_SERVICE_NAME = "git_pathology";
const LOG_PREFIX = "[GitPathologyService]";
const DEFAULT_OPTIONS: AnalysisOptions = {
since: "14d",
budget: 20,
cache: "auto",
};
function defaultBudget(): number {
const raw = process.env.GITPATHOLOGIST_BUDGET?.trim();
if (!raw) return DEFAULT_OPTIONS.budget;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_OPTIONS.budget;
}
export class GitPathologyService extends Service {
static serviceType: string = GIT_PATHOLOGY_SERVICE_NAME;
capabilityDescription =
"Forensic git-history analysis: per-surface health timeline, drift inflection detection, rot post-mortem.";
static async start(runtime: IAgentRuntime): Promise<GitPathologyService> {
logger.info(`${LOG_PREFIX} starting`);
return new GitPathologyService(runtime);
}
async stop(): Promise<void> {
// No background work to stop.
}
async runReport(
surface: SurfaceSpec,
overrides: Partial<AnalysisOptions> = {}
): Promise<PathologyReport> {
const options = { ...DEFAULT_OPTIONS, budget: defaultBudget(), ...overrides };
const cacheDir = defaultCacheDir(surface.repoRoot);
const cache = createReportCache(cacheDir);
const cacheKey = makeCacheKey({ surface: surface.path, since: options.since });
const currentHead = readHeadSha(surface.repoRoot);
if (options.cache !== "force") {
const cached = cache.read(cacheKey);
if (cached && cached.headSha === currentHead) {
logger.info(`${LOG_PREFIX} cache hit ${cacheKey.slice(0, 12)} surface=${surface.path}`);
return scrubSecretsDeep(cached);
}
if (options.cache === "read-only") {
throw new Error(
`gitpathology cache miss for ${surface.path} (HEAD changed or no prior report)`
);
}
}
const raw = scan(surface, { since: options.since });
if (raw.length === 0) {
const empty = scrubSecretsDeep(emptyReport(surface, options, currentHead, cacheKey));
cache.write(empty);
return empty;
}
const chronological = [...raw].reverse();
const classified = classify(chronological);
const points = score(classified);
const { peaks, drifts } = findInflections(points);
const { rotCauses, llmCalls } = await narrate(this.runtime ?? null, {
surfacePath: resolveSurfacePath(surface),
repoRoot: surface.repoRoot,
timeline: points,
drifts,
budget: options.budget,
});
const oldest = points[0]?.date ?? new Date().toISOString();
const newest = points[points.length - 1]?.date ?? new Date().toISOString();
const authors = Array.from(new Set(points.map((p) => p.author))).sort();
const report: PathologyReport = {
surface: surface.path,
repoRoot: surface.repoRoot,
window: { since: oldest, until: newest },
commitCount: points.length,
authors,
timeline: points,
peaks,
drifts,
rotCauses,
llmCalls,
headSha: currentHead,
generatedAt: new Date().toISOString(),
cacheKey,
};
const safeReport = scrubSecretsDeep(report);
cache.write(safeReport);
logger.info(
`${LOG_PREFIX} report written ${cacheKey.slice(0, 12)} surface=${surface.path} commits=${points.length} llm=${llmCalls}`
);
return safeReport;
}
listReports(repoRoot: string): CachedReportSummary[] {
return createReportCache(defaultCacheDir(repoRoot)).list();
}
}
function emptyReport(
surface: SurfaceSpec,
options: AnalysisOptions,
headSha: string,
cacheKey: string
): PathologyReport {
const now = new Date().toISOString();
return {
surface: surface.path,
repoRoot: surface.repoRoot,
window: { since: options.since, until: now },
commitCount: 0,
authors: [],
timeline: [],
peaks: [],
drifts: [],
rotCauses: [],
llmCalls: 0,
headSha,
generatedAt: now,
cacheKey,
};
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Shared types for @elizaos/plugin-gitpathologist.
*
* The pipeline produces a {@link PathologyReport} for a given {@link SurfaceSpec}.
* Phases:
* scan → {@link RawCommit}[]
* classify → {@link ClassifiedCommit}[]
* score → {@link CommitHealthPoint}[]
* inflect → peaks + drifts
* narrate → {@link RotCause}[]
*/
export type CommitType =
| "feature"
| "fix"
| "refactor"
| "revert"
| "chore"
| "wip"
| "merge"
| "other";
export type RotCategory =
| "rushed-fix"
| "scope-creep"
| "bad-merge"
| "revert-cycle"
| "churn-spiral"
| "other";
export interface SurfaceSpec {
/** Path or glob, relative to the repo root. */
path: string;
/** Repository root. Defaults to runtime workspace cwd. */
repoRoot: string;
}
export interface AnalysisOptions {
/** ISO date or relative window (e.g. "14d", "4w"). Default "14d". */
since: string;
/** Maximum LLM narration calls. Default 20. */
budget: number;
/** Cache policy. */
cache: "auto" | "force" | "read-only";
}
export interface FileTouch {
path: string;
added: number;
deleted: number;
status: "A" | "M" | "D" | "R" | "C" | "T" | "U" | "X";
/** Previous path when status is R (rename) or C (copy). */
fromPath?: string;
}
export interface RawCommit {
sha: string;
parents: string[];
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: FileTouch[];
/** Truncated diff for narration phase. Empty when scan was metadata-only. */
diffSnippet: string;
}
export interface ClassifiedCommit extends RawCommit {
type: CommitType;
scope?: string;
riskFlags: string[];
/** Whether classification came from rules (cheap) or the LLM (batched). */
classifiedBy: "rule" | "llm";
}
export interface CommitHealthPoint extends ClassifiedCommit {
/** Per-commit health delta. Negative = rot-leaning. */
delta: number;
/** Running exponentially-weighted moving average. */
score: number;
/** Total touched lines (added + deleted). */
churn: number;
}
export interface InflectionPoint {
sha: string;
date: string;
author: string;
score: number;
delta: number;
reasonShort: string;
}
export interface RotCause {
shaRange: [string, string];
category: RotCategory;
evidence: string[];
narrative: string;
}
export interface PathologyReport {
surface: string;
repoRoot: string;
window: { since: string; until: string };
commitCount: number;
authors: string[];
timeline: CommitHealthPoint[];
peaks: InflectionPoint[];
drifts: InflectionPoint[];
rotCauses: RotCause[];
/** Counter of LLM calls made for this analysis. */
llmCalls: number;
/** Sha of repo HEAD at time of analysis — used for incremental cache. */
headSha: string;
generatedAt: string;
cacheKey: string;
}
export type Operation = "report" | "list";
export interface OperationParams {
action?: Operation;
surface?: string;
since?: string;
budget?: number;
cache?: AnalysisOptions["cache"];
}
export interface CachedReportSummary {
cacheKey: string;
surface: string;
generatedAt: string;
headSha: string;
commitCount: number;
sizeBytes: number;
}
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist/node",
"declaration": true,
"declarationDir": "dist/node",
"declarationMap": true,
"noEmit": false,
"emitDeclarationOnly": true,
"rootDir": "src",
"ignoreDeprecations": "6.0",
"paths": {
"@elizaos/core": ["../../packages/core/dist/index.node.d.ts"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "__tests__", "../dist"]
}
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"declaration": true,
"declarationMap": true,
"paths": {
"@elizaos/core": ["../../packages/core/src/index.node.ts"],
"@elizaos/core/*": ["../../packages/core/src/*"],
"@elizaos/logger": ["../../packages/logger/src/index.ts"],
"@elizaos/logger/*": ["../../packages/logger/src/*"],
"@elizaos/plugin-gitpathologist": ["./src/index.ts"],
"@elizaos/plugin-gitpathologist/*": ["./src/*"]
},
"types": ["node"]
},
"include": ["src/**/*.ts", "__tests__/**/*.ts"],
"exclude": ["node_modules", "dist", "build.ts"]
}
@@ -0,0 +1,24 @@
/**
* Configures the gitpathologist Vitest suite; aliases @elizaos/core and
* @elizaos/logger to their TypeScript sources so tests run without a built dist.
*/
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["__tests__/**/*.test.ts"],
globals: true,
environment: "node",
testTimeout: 30000,
},
resolve: {
alias: {
// @elizaos/core's package.json exports point at dist/* which is not
// built in a fresh workspace. Vitest can resolve the source directly.
"@elizaos/core": path.resolve(__dirname, "../../packages/core/src/index.node.ts"),
"@elizaos/logger": path.resolve(__dirname, "../../packages/logger/src/index.ts"),
},
},
});