diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 7e46829..e5bdd3f 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -25,7 +25,7 @@ Open a full-screen dashboard displaying Git analytics: churn (commits per file),
**Export (all report dashboards).** Each report webview (Git Analytics, Hygiene Analytics, Session Briefing) offers two save paths: **↓ CSV / ↓ JSON** quick-save the report dialog-free to `.meridian/artifacts/` with a timestamped filename (per [ADR 014](./adr/014-dotdir-doctrine.md); the dir self-ignores so artifacts never enter git), and **Save as…** opens a dialog (format + location) for saving anywhere.
### **git.sessionBriefing**
-Generate a session-orientation summary. Aggregates git working-tree status, recent commits, run-log activity (`recentRuns`), git analytics (`activityWindow` — including momentum trends and a commit-frequency sparkline showing the shape behind the trend arrow), hygiene scan state (`hygieneSnapshot`), and a pending-change risk preview (`pendingChangeRisk` — each uncommitted file joined against the computed analytics risk model: churn, volatility, and risk tier, with files absent from the analytics window marked `new` (no history) or `cold` (changed but quiet — low, not unknown); a flag is raised when several high-risk files are in flight), and a pending-change companion preview (`pendingChangeCompanions` — files that historically ship in the same commit as your current edits but are not in the dirty set yet, i.e. possibly-forgotten siblings such as tests/types/docs; a flag is raised when several are likely missing) into a deterministic `SessionBriefing` record, then layers optional AI prose on top. Optional slices degrade gracefully when data is unavailable; the prose layer degrades to the raw aggregate when no language model is available. Useful for standup notes, context switching, pre-commit risk triage, or morning orientation.
+Generate a session-orientation summary. Aggregates git working-tree status, recent commits, run-log activity (`recentRuns`), git analytics (`activityWindow` — including momentum trends and a commit-frequency sparkline showing the shape behind the trend arrow), hygiene scan state (`hygieneSnapshot`), and a pending-change risk preview (`pendingChangeRisk` — each uncommitted file joined against the computed analytics risk model: churn, volatility, and risk tier, with files absent from the analytics window marked `new` (no history) or `cold` (changed but quiet — low, not unknown); a flag is raised when several high-risk files are in flight), and a pending-change companion preview (`pendingChangeCompanions` — files that historically ship in the same commit as your current edits but are not in the dirty set yet, i.e. possibly-forgotten siblings such as tests/types/docs; a flag is raised when several are likely missing), and a longitudinal pulse slice (`pulse` — movement since your previous briefing plus trend lines over the stored history in `.meridian/pulse/`; snapshots are captured automatically per briefing, throttled to one per 10 minutes, capped and local-only per [ADR 019](./adr/019-pulse-and-retention.md)) into a deterministic `SessionBriefing` record, then layers optional AI prose on top. Optional slices degrade gracefully when data is unavailable; the prose layer degrades to the raw aggregate when no language model is available. Useful for standup notes, context switching, pre-commit risk triage, or morning orientation.
---
@@ -41,7 +41,7 @@ Scan the workspace for cleanup candidates:
- **Log files**: stale `.log` files above age and size thresholds
- **Markdown files**: documentation artifacts for review or archival
-Respects `.gitignore` and `.meridian/.meridianignore` patterns. Returns a categorized list with file paths, sizes, ages, and reasons.
+Respects `.gitignore` and `.meridian/.meridianignore` patterns. Exclusions are ecosystem-aware ([ADR 018](./adr/018-ecosystem-registry.md)): envs, caches, vendored deps, and build outputs for JS/TS, Python, JVM (Maven/Gradle/Kotlin/Scala), Go, Rust, .NET, Ruby, Elixir, and more derive from a single registry (`src/ecosystems.ts`). Returns a categorized list with file paths, sizes, ages, and reasons.
### **hygiene.cleanup**
Delete specified files. Batch removal of candidates surfaced by `hygiene.scan`. Defaults to dry-run: deletion happens only with an explicit `dryRun: false`, and the user-facing path (**Delete File**) requires a modal confirmation first. Not exposed in the command palette.
@@ -56,6 +56,9 @@ Open a dashboard displaying Hygiene analytics: prune candidates over time, file-
- **Delete File** — remove a file flagged by the last scan (confirmation required).
- **Ignore File** — append the file's pattern to `.meridian/.meridianignore`.
+### **hygiene.pruneStorage** (Meridian Storage)
+Meridian polices its own storage ([ADR 019](./adr/019-pulse-and-retention.md)). The Hygiene view's **Meridian Storage** section shows the footprint of `.meridian/artifacts/` (exported reports), the run log, and the pulse history — including what the current retention policy would prune. **Prune Now** (inline action or `Hygiene: Prune Meridian Storage`) previews the effect, asks for confirmation, then deletes aged/surplus exports and compacts the run log. Retention is also enforced automatically at activation and after each report export.
+
---
## Sidebar Views & UI
@@ -81,6 +84,9 @@ All features respect workspace settings under the `meridian.*` namespace, includ
- `meridian.hygiene.prune.minAgeDays` — Minimum file age (days) before a file is a prune candidate (number, default: 30)
- `meridian.hygiene.prune.maxSizeMB` — Files larger than this (MB) are flagged when also older than `minAgeDays` (number, default: 1)
- `meridian.hygiene.prune.minLineCount` — Files with this many lines or more are flagged when also older than `minAgeDays`; 0 disables (number, default: 0)
+- `meridian.retention.artifacts.maxCount` — Keep at most N exported reports in `.meridian/artifacts/`; 0 disables (number, default: 50)
+- `meridian.retention.artifacts.maxAgeDays` — Prune exported reports older than N days; 0 disables (number, default: 30)
+- `meridian.retention.runLog.maxEvents` — Compact the run log to its newest N events at activation; 0 disables (number, default: 5000)
- `meridian.sessionBriefing.autoLaunch` — Open a Session Briefing on activation (boolean, default: false)
- `meridian.startup.enableFileWatchers` — Register file watchers for auto tree/status refresh (boolean, default: true)
@@ -90,6 +96,7 @@ Per-workspace Meridian state lives under `.meridian/` at the workspace root (see
- `.meridian/.meridianignore` — gitignore-syntax patterns excluded from hygiene scans. Editor syntax highlighting is provided via the built-in `ignore` language association. Legacy `.meridianignore` at the workspace root is auto-relocated on activation.
- `.meridian/settings.json` — sparse JSON overrides for `meridian.*` settings. Present keys take precedence over VS Code user/workspace settings; absent keys fall through. Example: `{ "hygiene.prune.minAgeDays": 7 }`.
+- `.meridian/pulse/` — local, self-gitignored pulse history (`pulse.v1.jsonl`) behind the session briefing's pulse slice; self-capping, no maintenance needed ([ADR 019](./adr/019-pulse-and-retention.md)).
---
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 1a6abfc..f37cc3a 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -13,14 +13,20 @@ deferred. (See [ADR 012](./adr/012-product-reanchor.md) for the re-anchor.)
## Now
-1. **Session-briefing UI polish** — the briefing is the connective tissue that
- makes the analytics/hygiene panels get reopened. Richer presentation over
- the existing `aggregateSessionBriefing()` record: actionable cards, inline
- panel links, quick filters. Prose stays optional and degrades to the raw
- aggregate when `vscode.lm` is unavailable.
+1. **Session-briefing UI polish (continued)** — richer presentation over the
+ `aggregateSessionBriefing()` record: actionable cards, inline panel links,
+ quick filters. The pulse slice (ADR 019) landed; remaining polish is
+ presentation-only. Prose stays optional and degrades to the raw aggregate
+ when `vscode.lm` is unavailable.
- Primary files: `src/domains/git/session-briefing-ui/`,
`src/domains/git/session-aggregator.ts`.
+2. **Agent-readable snapshot (stretch, file-shaped)** — a stable, versioned
+ JSON "latest" convention (e.g. `.meridian/latest/briefing.json`) that
+ Copilot/Cursor/CLI agents read directly, plus a documented pointer users
+ paste into their agent rules. No runtime integration, no LM-tool surface —
+ the inversion of the pre-ADR-012 approach.
+
---
## Deferred
@@ -38,6 +44,13 @@ deferred. (See [ADR 012](./adr/012-product-reanchor.md) for the re-anchor.)
## Done
+- **Ecosystem registry** (ADR 018) — single source for language/toolchain
+ semantics; JVM (Maven/Gradle/Kotlin/Scala) first-class; exclusion/bucket/
+ categorization drift structurally eliminated.
+- **Pulse history + retention + storage surface** (ADR 019) — longitudinal
+ pulse slice in the session briefing; self-policing retention for
+ `.meridian/artifacts/`, the run log, and pulse history; "Meridian Storage"
+ tree section with Prune Now.
- **2.0 re-anchor** (ADR 012) — retired the LLM-commodity, chat-participant,
LM-tool, workflow, and agent surfaces; kept the computed-insight core.
- **Foundations** — run log (ADR 009), dispatch lifecycle (ADR 008),
diff --git a/docs/adr/018-ecosystem-registry.md b/docs/adr/018-ecosystem-registry.md
new file mode 100644
index 0000000..2650e66
--- /dev/null
+++ b/docs/adr/018-ecosystem-registry.md
@@ -0,0 +1,127 @@
+# ADR 018 — Ecosystem Registry: Single Source for Language/Toolchain Semantics
+
+**Date:** 2026-07-03
+**Status:** Accepted
+
+## Context
+
+Meridian's knowledge of language ecosystems — which directory names are
+environments, caches, build outputs, or vendored dependencies, and which file
+extensions are sources, configs, or compiled artifacts — was spread across
+five independently hand-maintained lists in two files:
+
+1. `HYGIENE_SETTINGS.EXCLUDE_PATTERNS` (constants.ts) — hygiene scan exclusions
+2. `HYGIENE_ANALYTICS_EXCLUDE_PATTERNS` (constants.ts) — analytics walker exclusions
+3. `HEAVY_ARTIFACT_DIRS` (hygiene/analytics-service.ts) — never-recurse placeholder dirs
+4. `COLLECTION_BUCKETS` (hygiene/analytics-utils.ts) — Collections bucketing
+5. `ARTIFACT_EXTS` / `ARTIFACT_DIRS` / `SOURCE_EXTS` / `CONFIG_EXTS` (analytics-utils.ts) — categorization
+
+The lists had already drifted: `.gradle` appeared in 2 and 4 but not 1;
+`target` in 4 and 5 but not 1 or 2. The practical consequence was a
+**JVM-shaped hole**: pointing the hygiene scan at a Maven repository descended
+into `target/` and flooded candidates with `.class` files; `pom.xml` and
+`application.properties` categorized as "other"; `.jar`/`.war` were not
+artifacts. The tested stacks (Python, JS/TS) were rich; the enterprise Java
+footprint was effectively unsupported.
+
+## Decision
+
+1. **`src/ecosystems.ts` is the single source of ecosystem semantics.** It
+ declares `EcosystemProfile` entries (`envDirs`, `cacheDirs`, `buildDirs`,
+ `vendorDirs`, `artifactExts`, `sourceExts`, `configExts`) per ecosystem
+ (common, node, python, jvm, go, rust, dotnet, ruby, elixir, native,
+ terraform, dart, haskell, clojure). The module imports nothing, so every
+ layer — including `constants.ts` — can consume it without cycles. Adding
+ ecosystem coverage is one profile entry; consumer lists are never edited.
+
+2. **All five former lists are derived unions.** Derivation semantics:
+ - Hygiene scan excludes = base + env ∪ cache ∪ vendor ∪ build (the scan
+ hunts stray files, not generated-tree contents).
+ - Analytics excludes = base + env ∪ cache ∪ vendor. Build dirs are
+ **recursed** so contents surface as prune candidates.
+ - Heavy/placeholder dirs = env ∪ cache ∪ vendor — definitionally identical
+ to the analytics exclusion set, so exclusion and placeholder membership
+ cannot drift apart.
+ - Collections buckets map 1:1 to the four dir kinds.
+ - `ARTIFACT_DIRS` (categorization) = env ∪ cache ∪ build; vendor dirs are
+ never recursed, so their contents never reach `categorize()`.
+
+3. **Suffix-matched names stay literal.** `*.egg-info` cannot be expressed in
+ a name-keyed registry; the glob in `constants.ts` and the `endsWith` check
+ in the analytics walker remain special cases, documented at both sites.
+
+4. **JVM profile.** `target` (build); `.gradle`, `.kotlin`, `.bloop`,
+ `.metals` (caches); `.class`, `.jar`, `.war`, `.ear`, `.hprof` (artifacts);
+ `.java`, `.kt`, `.kts`, `.scala`, `.groovy` (sources); `.xml`,
+ `.properties`, `.gradle` (configs). Eclipse `.settings/` joins
+ `WORKSPACE_EXCLUDE_BASE` as IDE metadata (same class as `.idea`/`.vscode`).
+
+5. **Deliberate omissions.** Bare `bin/` is NOT excluded or bucketed — script
+ `bin/` dirs with real source are common, and the false-positive cost
+ outweighs the Eclipse/.NET benefit. `packages/` is likewise omitted
+ entirely (revised in the production-readiness pass): yarn/pnpm monorepos
+ keep first-party source there, and silently blinding the hygiene scan to
+ it costs more than legacy NuGet layouts gain — NuGet shops opt back out
+ via `.meridianignore`. `obj/` (dotnet) IS excluded (the name is
+ toolchain-specific enough). `env/` IS excluded — kept for parity with
+ venv detection; a first-party `env/` dir is rarer than a virtualenv named
+ `env`, and `.meridianignore` remains the escape hatch. All pinned by test.
+
+6. **Glob forms are per-consumer.** `dirExcludeGlobs(sets, form)` emits
+ descendant-only globs (`**/
/**`) by default — the hygiene scan
+ matches file paths, and the bare form would wrongly exclude a *file*
+ named `dist` or `deps`. The analytics walker requests `"both"` because it
+ pattern-matches directory paths themselves to stop recursion and emit the
+ placeholder row. Side effect worth naming: pre-registry, the Python heavy
+ dirs lacked bare forms in the analytics list, so the walker recursed one
+ readdir level into every venv and emitted no placeholder for it — the
+ uniform derivation fixed that latent inconsistency.
+
+## Intended behavior changes
+
+All strictly toward correctness; guarded by legacy-superset tests in
+`tests/ecosystems.test.ts` (every pre-registry pattern must appear in the
+derived sets, with documented exceptions):
+
+- The hygiene scan now excludes `target/`, `.gradle/`, `.yarn/`, `vendor/`,
+ `deps/`, `_build/`, `packages/`, `.terraform/`, etc. (previously flooded).
+- Files under build-output dirs (`dist/`, `target/`, `_build/`, …) categorize
+ as `artifact`, making aged build products prune candidates — the
+ generalized form of the Java `target/` fix.
+- `_build/` (Elixir) is no longer analytics-excluded: as a build dir it is
+ now recursed for prune candidates, consistent with `dist/` and `target/`.
+- `deps/` reclassifies from buildOutputs to vendoredDeps (Mix deps are
+ fetched packages); `packages/` leaves the registry entirely (see
+ decision 5) — it is no longer analytics-excluded, heavy-placeholdered,
+ or bucketed.
+- New cache coverage: `coverage/`, `.nyc_output/`, `.next/`, `.nuxt/`,
+ `.parcel-cache/`, `.kotlin/`, `.bloop/`, `.metals/` now excluded and
+ placeholdered everywhere, not just in whichever list happened to have them.
+
+## Alternatives considered
+
+**Flat, better-commented union sets (no profiles).** Delivers the same
+anti-drift derivation with slightly less structure, but re-creates
+drift-within-file (a new ecosystem still edits seven sets) and cannot be
+fixture-tested per ecosystem. Rejected.
+
+**Per-ecosystem enable/disable setting.** No demonstrated need; exclusion
+supersets are cheap and harmless on repos that lack the dirs. Rejected as
+speculative surface (YAGNI).
+
+**Registry in `src/domains/hygiene/`.** Rejected: `constants.ts` must consume
+it, and constants → domains would invert layering.
+
+## Consequences
+
+- **Positive.** Drift is structurally impossible; JVM repos are first-class;
+ future ecosystems are one-entry additions with a fixture-test pattern to
+ copy. Exclusion lists, placeholders, buckets, and categorization can no
+ longer disagree.
+- **Cost.** One more top-level module. Derived globs are marginally broader
+ than the old hand lists (more dirs excluded) — this is the point, but it
+ does mean scan results change on repos that previously surfaced
+ generated-tree noise.
+- **Cross-references:** ADR 014 (dotdir doctrine — `.meridian` remains in the
+ walker's skip set), `tests/ecosystems.test.ts` (superset + disjointness +
+ JVM fixtures).
diff --git a/docs/adr/019-pulse-and-retention.md b/docs/adr/019-pulse-and-retention.md
new file mode 100644
index 0000000..b0a301f
--- /dev/null
+++ b/docs/adr/019-pulse-and-retention.md
@@ -0,0 +1,109 @@
+# ADR 019 — Pulse History, Retention Engine, and the Storage Surface
+
+**Date:** 2026-07-03
+**Status:** Accepted
+**Extends:** ADR 009 (schema versioning), ADR 011 (additive briefing slices), ADR 014 (dotdir doctrine)
+
+## Context
+
+Two gaps, one theme — Meridian observes the workspace over time but kept no
+history, and polices the user's hygiene while leaving its own storage
+unbounded:
+
+1. **No longitudinal signal.** Every report was point-in-time. The session
+ briefing could not answer "is this repo getting healthier or worse since
+ yesterday?" — the product's stated identity ("general pulse of the project
+ as it evolves") had no data substrate.
+2. **Unbounded self-storage.** The run log
+ (`.vscode/meridian/run-log.v1.jsonl`) was append-only with no rotation, and
+ `.meridian/artifacts/` accumulated timestamped exports forever. ADR 014
+ explicitly deferred retention; an enterprise-adopted tool with
+ unbounded-growth files is a liability.
+
+## Decision
+
+### 1. Pulse store (`.meridian/pulse/pulse.v1.jsonl`)
+
+- **Versioned JSONL, ADR 009 discipline**: `PulseSnapshotV1`
+ (`schemaVersion: 1`), append-rejects unsupported versions, serialized writes
+ through a queue (`infrastructure/pulse-store.ts`, modeled on `FileRunLog`).
+- **Tolerant reader.** Unlike the run log's strict reader, malformed or
+ future-versioned lines are skipped with a warning, not a hard error — pulse
+ is a fail-soft peripheral by contract; a torn line must not kill briefings.
+- **Location & sharing.** Lives under `.meridian/` (workspace insight, not
+ host-runtime trace), but **self-ignored** via the artifacts-dir `.gitignore`
+ pattern: an append-only JSONL shared through git would merge-conflict across
+ machines and interleave unrelated hosts' sessions.
+- **Capture point.** `aggregateSessionBriefing()` reads history, builds the
+ current snapshot from data already in scope (git core, activity window,
+ hygiene snapshot, pending risk — zero new analysis), and appends. Appends
+ are **throttled** (`PULSE.MIN_APPEND_INTERVAL_MS`, 10 min): several
+ briefings in one sitting are one working session, not several data points.
+ Optional snapshot fields are absent when unmeasured — never zero-filled.
+- **Briefing slice.** Additive, optional `SessionBriefing.pulse`
+ (ADR 011 rule): deltas vs the previous snapshot (only for fields measured on
+ both sides), a bounded series (`PULSE.SERIES_LIMIT`) ending at "now", and an
+ `appended` marker. Rendered as delta cards + inline-SVG trend lines in the
+ briefing webview; summarized in the deterministic text; passed to the
+ optional prose layer. Fail-soft flags: "Pulse history unavailable" /
+ "Pulse history not recorded".
+
+### 2. Retention engine (`infrastructure/retention.ts`)
+
+- **Scope is a closed list**: `.meridian/artifacts/` (count + age caps),
+ the run log (event cap via new `RunLog.compact()`), `.meridian/pulse/`
+ (self-capping at `PULSE.MAX_SNAPSHOTS` inside the store). Nothing else is
+ ever touched; artifact deletion joins only self-listed basenames; the
+ self-ignore `.gitignore` is never a prune target.
+- **Settings-backed policy** through the ADR 013 chokepoint:
+ `retention.artifacts.maxCount` (50), `retention.artifacts.maxAgeDays` (30),
+ `retention.runLog.maxEvents` (5000); `0` disables a rule.
+- **Lazy enforcement, no daemons** (ADR 014 doctrine): once at activation
+ (fire-and-forget, never blocks) and after each quick-save export. Run-log
+ compaction executes **inside the write queue** — the only race-safe point —
+ as a raw-line tail-keep with atomic tmp+rename (`jsonl-tail.ts`), never
+ parsing, so v1 schema pinning is untouched.
+- **Plan/act share one function.** `planArtifactPrune()` is pure; the storage
+ surface's "would prune" preview and the actual prune both consume it, so
+ they cannot disagree.
+
+### 3. Storage surface
+
+- Per the ADR 006 matrix: a small structured record with glance intent →
+ **tree, not webview**. A "Meridian Storage" section in the Hygiene view
+ (rows: Exported Reports, Run Log, Pulse History; footprint + would-prune
+ preview), fail-soft (omitted on error). All affordances are Meridian-owned
+ surfaces per ADR 016.
+- `hygiene.storageStatus` / `hygiene.pruneStorage` are router-dispatched
+ domain handlers (Result monad, run-log observability for free).
+ `meridian.hygiene.pruneStorage` is an ADR 005 specialized registration:
+ status preview → confirmation modal → prune → toast summary + tree refresh.
+
+## Alternatives considered
+
+- **Pulse in the run log.** Rejected: different lifecycle (host-runtime trace
+ vs workspace insight), different reader tolerance, and it would force a
+ run-log schema bump ADR 012 explicitly reserved for substantive needs.
+- **Committed (shared) pulse history.** Rejected — merge conflicts and
+ cross-host interleaving; local-first matches the no-exfil posture.
+- **Timer-based retention.** Rejected per ADR 014's no-daemon doctrine; write
+ paths and activation are sufficient enforcement points.
+- **Webview storage report.** Rejected — no chart/table density that justifies
+ a panel (ADR 006 Rule 2).
+
+## Consequences
+
+- **Positive.** The briefing gains the longitudinal dimension the product
+ thesis promised; Meridian-owned storage is bounded by default and visibly
+ self-policed — "we point our own hygiene engine at ourselves" is now a
+ demonstrable claim.
+- **Cost.** Three new settings; one new dotdir subdir; retention defaults
+ (50 files / 30 days / 5000 events) silently delete aged exports — mitigated
+ by documented settings, `0`-disables semantics, and the storage node making
+ the policy visible.
+- **Multi-root caveat.** Pulse store and retention use `workspaceFolders[0]`,
+ matching the existing single-root assumption (ADR 014).
+- **Cross-references:** `src/infrastructure/{pulse-store,retention,jsonl-tail}.ts`,
+ `src/domains/hygiene/storage-handler.ts`, `SessionBriefing.pulse`
+ (`src/domains/git/types.ts`), tests
+ `pulseStore/retention/storageHandler/run-log/sessionAggregator`.
diff --git a/package.json b/package.json
index 5ba0a1f..0595734 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,11 @@
"title": "Hygiene: Ignore File",
"category": "Meridian"
},
+ {
+ "command": "meridian.hygiene.pruneStorage",
+ "title": "Hygiene: Prune Meridian Storage",
+ "icon": "$(trash)"
+ },
{
"command": "meridian.git.refresh",
"title": "Git: Refresh View",
@@ -270,6 +275,24 @@
],
"description": "File categories that are automatically flagged as prune candidates when older than minAgeDays."
},
+ "meridian.retention.artifacts.maxCount": {
+ "type": "number",
+ "default": 50,
+ "minimum": 0,
+ "description": "Keep at most this many exported reports in .meridian/artifacts/ (oldest pruned first, enforced at activation and after each quick-save). Set to 0 to disable."
+ },
+ "meridian.retention.artifacts.maxAgeDays": {
+ "type": "number",
+ "default": 30,
+ "minimum": 0,
+ "description": "Prune exported reports in .meridian/artifacts/ older than this many days (enforced at activation and after each quick-save). Set to 0 to disable."
+ },
+ "meridian.retention.runLog.maxEvents": {
+ "type": "number",
+ "default": 5000,
+ "minimum": 0,
+ "description": "Compact the run log (.vscode/meridian/run-log.v1.jsonl) to its newest N events at activation. Set to 0 to disable."
+ },
"meridian.sessionBriefing.autoLaunch": {
"type": "boolean",
"default": false,
@@ -365,6 +388,10 @@
"command": "meridian.hygiene.deleteFile",
"when": "false"
},
+ {
+ "command": "meridian.hygiene.pruneStorage",
+ "when": "workspaceFolderCount > 0"
+ },
{
"command": "meridian.hygiene.ignoreFile",
"when": "false"
@@ -420,6 +447,11 @@
"command": "meridian.hygiene.ignoreFile",
"when": "view == meridian.hygiene.view && viewItem == file",
"group": "hygiene@2"
+ },
+ {
+ "command": "meridian.hygiene.pruneStorage",
+ "when": "view == meridian.hygiene.view && viewItem == storageSection",
+ "group": "inline"
}
],
"view/item/inline": [
diff --git a/src/constants.ts b/src/constants.ts
index dbe3ca2..b04e638 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -2,9 +2,19 @@
* Centralized, typed constants for the entire application.
* No magic strings or numbers; all thresholds, names, and patterns are explicit.
*
- * Organized by domain for clarity.
+ * Organized by domain for clarity. Ecosystem-specific directory/extension
+ * knowledge lives in ecosystems.ts (ADR 018); the exclusion lists below are
+ * derived from it, never hand-extended.
*/
+import {
+ ECOSYSTEM_BUILD_DIRS,
+ ECOSYSTEM_CACHE_DIRS,
+ ECOSYSTEM_ENV_DIRS,
+ ECOSYSTEM_VENDOR_DIRS,
+ dirExcludeGlobs,
+} from "./ecosystems";
+
// ============================================================================
// Cache Configuration
// ============================================================================
@@ -15,6 +25,13 @@ export const CACHE_SETTINGS = {
/** Dead code scan cache TTL in milliseconds (5 minutes) */
DEAD_CODE_TTL_MS: 5 * 60 * 1000,
+
+ /**
+ * Storage-status cache TTL (30s) — the hygiene tree rebuilds on debounced
+ * file-watcher events; without this, every rebuild re-reads the full run
+ * log just to line-count it. Prune invalidates explicitly.
+ */
+ STORAGE_STATUS_TTL_MS: 30 * 1000,
} as const;
// ============================================================================
@@ -27,6 +44,9 @@ export const MERIDIAN_DIR = ".meridian";
/** Generated-report / artifact subdir under the dotdir (self-ignored). */
export const MERIDIAN_ARTIFACTS_DIR = "artifacts";
+/** Pulse-history subdir under the dotdir (self-ignored, ADR 019). */
+export const MERIDIAN_PULSE_DIR = "pulse";
+
// ============================================================================
// Workspace Exclusion Base — shared across git and hygiene analytics.
// Domain-specific lists extend via spread.
@@ -37,8 +57,17 @@ export const WORKSPACE_EXCLUDE_BASE = [
"**/.git/**",
"**/.vscode/**",
"**/.idea/**",
+ // Eclipse per-project IDE metadata — same class as .vscode/.idea.
+ "**/.settings/**",
] as const;
+/**
+ * Python packaging metadata dirs match by SUFFIX (`.egg-info`), which the
+ * name-keyed ecosystem registry cannot express — kept as a literal glob here
+ * and as an `endsWith` check in the hygiene analytics walker.
+ */
+const EGG_INFO_GLOB = "**/*.egg-info/**";
+
// ============================================================================
// Hygiene Configuration
// ============================================================================
@@ -58,28 +87,20 @@ export const HYGIENE_SETTINGS = {
*/
MAX_FILE_SIZE_BYTES: 1 * 1024 * 1024,
- /** File patterns to exclude from hygiene checks */
+ /**
+ * File patterns to exclude from hygiene checks — every ecosystem env,
+ * cache, vendor, AND build dir (the scan hunts stray files, not the
+ * contents of generated trees). Derived from the ecosystem registry.
+ */
EXCLUDE_PATTERNS: [
...WORKSPACE_EXCLUDE_BASE,
- // Build / output
- "**/dist/**",
- "**/build/**",
- "**/out/**",
- "**/bundled/**",
- // Python runtime & tooling
- "**/.venv/**",
- "**/venv/**",
- "**/__pycache__/**",
- "**/.pytest_cache/**",
- "**/.mypy_cache/**",
- "**/.ruff_cache/**",
- "**/.tox/**",
- "**/.eggs/**",
- "**/*.egg-info/**",
- // JS/TS coverage & caches
- "**/coverage/**",
- "**/.nyc_output/**",
- "**/.cache/**",
+ ...dirExcludeGlobs([
+ ECOSYSTEM_ENV_DIRS,
+ ECOSYSTEM_CACHE_DIRS,
+ ECOSYSTEM_VENDOR_DIRS,
+ ECOSYSTEM_BUILD_DIRS,
+ ]),
+ EGG_INFO_GLOB,
] as readonly string[],
/** Log file patterns to detect */
@@ -91,51 +112,31 @@ export const HYGIENE_SETTINGS = {
// ============================================================================
// Hygiene Analytics — lighter exclusion set for the analytics scan.
-// Unlike HYGIENE_SETTINGS.EXCLUDE_PATTERNS, we keep dist/, build/, out/, etc.
-// so they are surfaced as prune candidates. Heavy dirs (node_modules, venv,
-// __pycache__) are excluded from recursion but get a single "exists" placeholder
-// so they still show in the report without scanning contents.
+// Unlike HYGIENE_SETTINGS.EXCLUDE_PATTERNS, build-output dirs (dist/, build/,
+// out/, target/, _build/, …) are NOT excluded here, so their contents are
+// recursed and surfaced as prune candidates. Env/cache/vendor dirs are
+// excluded from recursion but get a single "exists" placeholder — the
+// walker's heavy-dir set derives from the same registry, so exclusion and
+// placeholder membership cannot drift apart.
// ============================================================================
export const HYGIENE_ANALYTICS_EXCLUDE_PATTERNS = [
...WORKSPACE_EXCLUDE_BASE,
- // Python runtime & tooling
- "**/.venv/**",
- "**/venv/**",
- "**/__pycache__/**",
- "**/.pytest_cache/**",
- "**/.mypy_cache/**",
- "**/.ruff_cache/**",
- "**/.tox/**",
- "**/.eggs/**",
- "**/*.egg-info/**",
- // Package managers & build tool caches
- "**/.yarn/**",
- "**/.pnpm-store/**",
- "**/vendor/**",
- "**/vendor",
- "**/.bundle/**",
- "**/.gradle/**",
- "**/packages/**",
- "**/packages",
- "**/.terraform/**",
- "**/.terraform",
- "**/.dart_tool/**",
- "**/.dart_tool",
- "**/deps/**",
- "**/deps",
- "**/_build/**",
- "**/_build",
- "**/.stack-work/**",
- "**/.stack-work",
- "**/.cpcache/**",
- "**/.cpcache",
+ // "both" forms: the analytics walker pattern-matches directory paths
+ // themselves (to stop recursion and emit placeholder rows), which the
+ // bare "**/" form covers.
+ ...dirExcludeGlobs([ECOSYSTEM_ENV_DIRS, ECOSYSTEM_CACHE_DIRS, ECOSYSTEM_VENDOR_DIRS], "both"),
+ EGG_INFO_GLOB,
] as const;
// ============================================================================
// Telemetry Event Types
// ============================================================================
+// Command lifecycle only — the workflow/agent/cache/user-action kinds were
+// pruned with their dead consumers (ADR 012 fallout; security-hardening pass).
+// The run-log schema's inert RunEventSource members are a separate,
+// deliberate retention.
export const TELEMETRY_EVENT_KINDS = {
COMMAND_STARTED: "COMMAND_STARTED",
COMMAND_COMPLETED: "COMMAND_COMPLETED",
@@ -228,6 +229,29 @@ export const SESSION_BRIEFING = {
FAILED_RUNS_FLAG_THRESHOLD: 1,
} as const;
+// ============================================================================
+// Pulse History (ADR 019)
+// ============================================================================
+
+/**
+ * Bounds for the longitudinal pulse store (`.meridian/pulse/pulse.v1.jsonl`)
+ * and the additive `SessionBriefing.pulse` slice derived from it.
+ */
+export const PULSE = {
+ /** Hard cap on stored snapshots; the store tail-compacts past this on append. */
+ MAX_SNAPSHOTS: 500,
+
+ /** Max history points carried into the briefing's pulse series (wire bound). */
+ SERIES_LIMIT: 30,
+
+ /**
+ * Minimum gap between stored snapshots. Repeated briefings inside the gap
+ * still render the pulse slice, but do not append — several briefings in
+ * one sitting are one working session, not several data points.
+ */
+ MIN_APPEND_INTERVAL_MS: 10 * 60 * 1000,
+} as const;
+
// ============================================================================
// Pending-Change Risk
// ============================================================================
diff --git a/src/domains/git/service.ts b/src/domains/git/service.ts
index 4a2b7aa..0d4e422 100644
--- a/src/domains/git/service.ts
+++ b/src/domains/git/service.ts
@@ -20,6 +20,7 @@ import {
GenerateProseFn,
} from "../../types";
import { RunLog } from "../../infrastructure/run-log";
+import { PulseStore } from "../../infrastructure/pulse-store";
import {
createStatusHandler,
createPullHandler,
@@ -54,6 +55,7 @@ export class GitDomainService implements DomainService {
private readonly runLog: RunLog | undefined;
private readonly getHygieneScan: HygieneScanGetter | undefined;
+ private readonly pulseStore: PulseStore | undefined;
constructor(
gitProvider: GitProvider,
@@ -61,12 +63,14 @@ export class GitDomainService implements DomainService {
workspaceRoot: string = process.cwd(),
generateProseFn?: GenerateProseFn,
runLog?: RunLog,
- getHygieneScan?: HygieneScanGetter
+ getHygieneScan?: HygieneScanGetter,
+ pulseStore?: PulseStore
) {
this.gitProvider = gitProvider;
this.logger = logger;
this.runLog = runLog;
this.getHygieneScan = getHygieneScan;
+ this.pulseStore = pulseStore;
// Initialize analytics — pass workspace root so git log runs in the correct repo
this.analyzer = new GitAnalyzer(workspaceRoot);
@@ -85,6 +89,7 @@ export class GitDomainService implements DomainService {
runLog: this.runLog,
gitAnalyzer: this.analyzer,
getHygieneScan: this.getHygieneScan,
+ pulseStore: this.pulseStore,
logger,
} satisfies SessionBriefingSources,
generateProseFn
@@ -145,7 +150,8 @@ export function createGitDomain(
workspaceRoot: string = process.cwd(),
generateProseFn?: GenerateProseFn,
runLog?: RunLog,
- getHygieneScan?: HygieneScanGetter
+ getHygieneScan?: HygieneScanGetter,
+ pulseStore?: PulseStore
): GitDomainService {
- return new GitDomainService(gitProvider, logger, workspaceRoot, generateProseFn, runLog, getHygieneScan);
+ return new GitDomainService(gitProvider, logger, workspaceRoot, generateProseFn, runLog, getHygieneScan, pulseStore);
}
diff --git a/src/domains/git/session-aggregator.ts b/src/domains/git/session-aggregator.ts
index 7f4bac8..c984007 100644
--- a/src/domains/git/session-aggregator.ts
+++ b/src/domains/git/session-aggregator.ts
@@ -16,6 +16,7 @@ import {
success,
} from "../../types";
import { RunLog } from "../../infrastructure/run-log";
+import { PulseSnapshotV1, PulseStore, PULSE_SCHEMA_VERSION } from "../../infrastructure/pulse-store";
import { GitAnalyzer } from "./analytics-service";
import { CoChangePair, FileMetric } from "./analytics-types";
import { normalizeRenamePath } from "./git-path";
@@ -28,8 +29,9 @@ import {
PendingChangeFile,
PendingChangeCompanions,
PendingCompanion,
+ PulseSlice,
} from "./types";
-import { SESSION_BRIEFING, PENDING_RISK, COMPANIONS } from "../../constants";
+import { SESSION_BRIEFING, PENDING_RISK, COMPANIONS, PULSE } from "../../constants";
export type HygieneScanGetter = () => { scan: WorkspaceScan; scannedAt: string } | undefined;
@@ -38,6 +40,7 @@ export interface SessionBriefingSources {
runLog: RunLog | undefined;
gitAnalyzer: GitAnalyzer;
getHygieneScan: HygieneScanGetter | undefined;
+ pulseStore?: PulseStore;
logger: Logger;
options?: { recentRunLimit?: number; recentCommitLimit?: number };
}
@@ -183,10 +186,61 @@ function computePendingChangeCompanions(
};
}
+/**
+ * Optional snapshot fields carried into the pulse deltas. A delta exists only
+ * when the field was measured on BOTH sides (absence means "not measured",
+ * never zero — matching PulseSnapshotV1).
+ */
+const PULSE_DELTA_FIELDS = [
+ "commitsInWindow",
+ "filesTouched",
+ "deadFileCount",
+ "largeFileCount",
+ "deadCodeItemCount",
+] as const;
+
+/**
+ * Build the pulse slice from stored history plus the just-captured current
+ * snapshot. Pure, exported for tests. `series` includes `current` as its
+ * final point regardless of whether the throttle let it be stored — the
+ * chart should always end at "now".
+ */
+export function buildPulseSlice(
+ history: ReadonlyArray,
+ current: PulseSnapshotV1,
+ appended: boolean
+): PulseSlice {
+ const previous = history.length > 0 ? history[history.length - 1] : undefined;
+ const slice: PulseSlice = {
+ series: [...history, current]
+ .slice(-PULSE.SERIES_LIMIT)
+ .map((s) => ({
+ timestampMs: s.timestampMs,
+ uncommittedCount: s.uncommittedCount,
+ ...(s.commitsInWindow !== undefined ? { commitsInWindow: s.commitsInWindow } : {}),
+ ...(s.deadFileCount !== undefined ? { deadFileCount: s.deadFileCount } : {}),
+ })),
+ appended,
+ };
+ if (previous) {
+ slice.previousAt = new Date(previous.timestampMs).toISOString();
+ const deltas: NonNullable = {
+ uncommittedCount: current.uncommittedCount - previous.uncommittedCount,
+ };
+ for (const field of PULSE_DELTA_FIELDS) {
+ const curr = current[field];
+ const prev = previous[field];
+ if (curr !== undefined && prev !== undefined) deltas[field] = curr - prev;
+ }
+ slice.deltas = deltas;
+ }
+ return slice;
+}
+
export async function aggregateSessionBriefing(
sources: SessionBriefingSources
): Promise> {
- const { gitProvider, runLog, gitAnalyzer, getHygieneScan, logger, options } = sources;
+ const { gitProvider, runLog, gitAnalyzer, getHygieneScan, pulseStore, logger, options } = sources;
const recentRunLimit = options?.recentRunLimit ?? SESSION_BRIEFING.RECENT_RUN_LIMIT;
const recentCommitLimit = options?.recentCommitLimit ?? SESSION_BRIEFING.RECENT_COMMIT_LIMIT;
@@ -337,6 +391,52 @@ export async function aggregateSessionBriefing(
flags.push("No hygiene scan yet");
}
+ // ── Pulse history — fail-soft ────────────────────────────────────────────
+ let pulse: PulseSlice | undefined;
+ if (pulseStore) {
+ const historyResult = await pulseStore.readLatest(PULSE.SERIES_LIMIT);
+ if (historyResult.kind === "err") {
+ logger.warn("Session briefing: pulse history read failed", "aggregateSessionBriefing", historyResult.error);
+ flags.push("Pulse history unavailable");
+ } else {
+ const history = historyResult.value;
+ const previous = history.length > 0 ? history[history.length - 1] : undefined;
+ const current: PulseSnapshotV1 = {
+ schemaVersion: PULSE_SCHEMA_VERSION,
+ timestampMs: Date.now(),
+ branch: status.branch,
+ uncommittedCount: uncommitted.length,
+ ...(activityWindow
+ ? { commitsInWindow: activityWindow.commitsInWindow, filesTouched: activityWindow.filesTouched }
+ : {}),
+ ...(hygieneSnapshot
+ ? {
+ deadFileCount: hygieneSnapshot.deadFileCount,
+ largeFileCount: hygieneSnapshot.largeFileCount,
+ logFileCount: hygieneSnapshot.logFileCount,
+ deadCodeItemCount: hygieneSnapshot.deadCodeItemCount,
+ }
+ : {}),
+ ...(pendingChangeRisk ? { hotspotCount: pendingChangeRisk.hotspotCount } : {}),
+ };
+
+ let appended = false;
+ const throttled =
+ previous !== undefined &&
+ current.timestampMs - previous.timestampMs < PULSE.MIN_APPEND_INTERVAL_MS;
+ if (!throttled) {
+ const appendResult = await pulseStore.append(current);
+ if (appendResult.kind === "err") {
+ logger.warn("Session briefing: pulse append failed", "aggregateSessionBriefing", appendResult.error);
+ flags.push("Pulse history not recorded");
+ } else {
+ appended = true;
+ }
+ }
+ pulse = buildPulseSlice(history, current, appended);
+ }
+ }
+
return success({
generatedAt: new Date().toISOString(),
branch: status.branch,
@@ -352,5 +452,6 @@ export async function aggregateSessionBriefing(
hygieneSnapshot,
pendingChangeRisk,
pendingChangeCompanions,
+ pulse,
});
}
diff --git a/src/domains/git/session-briefing-ui/index.html b/src/domains/git/session-briefing-ui/index.html
index a92e027..da0f392 100644
--- a/src/domains/git/session-briefing-ui/index.html
+++ b/src/domains/git/session-briefing-ui/index.html
@@ -26,6 +26,15 @@
Session Briefing
+
+
Pulse
+ ⓘ
+
+
+
+
+
+
Activity
diff --git a/src/domains/git/session-briefing-ui/script.js b/src/domains/git/session-briefing-ui/script.js
index 9c5ed02..31846a1 100644
--- a/src/domains/git/session-briefing-ui/script.js
+++ b/src/domains/git/session-briefing-ui/script.js
@@ -77,6 +77,7 @@
renderBranchBar(report);
renderSummaryCards(report);
renderFlags(report.flags);
+ renderPulse(report.pulse);
renderActivity(report.activityWindow);
renderHygiene(report.hygieneSnapshot);
renderRecentRuns(report.recentRuns);
@@ -179,7 +180,7 @@
// trend arrow. No Chart.js dependency (this webview ships none). Returns ""
// for degenerate input (missing, <2 points, or all-zero) so the caller can
// hide the block, mirroring the topChurnFiles empty-guard.
- function sparklineSvg(series) {
+ function sparklineSvg(series, label) {
var data = Array.isArray(series) ? series.map(Number).filter(function (n) {
return isFinite(n);
}) : [];
@@ -187,6 +188,7 @@
var max = Math.max.apply(null, data);
var min = Math.min.apply(null, data);
if (max <= 0) return "";
+ var ariaLabel = label || "Commit frequency trend";
var W = 240, H = 44, padY = H * 0.12;
var span = max - min;
@@ -200,12 +202,73 @@
var d = "M" + pts.join(" L");
return '';
}
+ // ── Pulse (longitudinal history, ADR 019) ──────────────────────────
+
+ function signed(v) {
+ return v > 0 ? "+" + v : String(v);
+ }
+
+ function pulseSeriesOf(series, key) {
+ var out = [];
+ for (var i = 0; i < series.length; i++) {
+ if (typeof series[i][key] === "number") out.push(series[i][key]);
+ }
+ return out;
+ }
+
+ function renderPulse(p) {
+ var section = document.getElementById("pulseSection");
+ if (!p) {
+ section.style.display = "none";
+ return;
+ }
+ section.style.display = "";
+
+ var hint = document.getElementById("pulseHint");
+ hint.textContent = p.previousAt
+ ? "(since " + new Date(p.previousAt).toLocaleString() + ")"
+ : "(first snapshot — history starts now)";
+
+ var cards = [];
+ if (p.deltas) {
+ var d = p.deltas;
+ var deltaDefs = [
+ ["Commits Δ", d.commitsInWindow],
+ ["Files Touched Δ", d.filesTouched],
+ ["Dead Files Δ", d.deadFileCount],
+ ["Large Files Δ", d.largeFileCount],
+ ["Dead Code Δ", d.deadCodeItemCount],
+ ["Uncommitted Δ", d.uncommittedCount],
+ ];
+ for (var i = 0; i < deltaDefs.length; i++) {
+ if (typeof deltaDefs[i][1] === "number") {
+ cards.push(metricCard(deltaDefs[i][0], signed(deltaDefs[i][1])));
+ }
+ }
+ }
+ document.getElementById("pulseMetrics").innerHTML = cards.join("");
+
+ var series = p.series || [];
+ var blocks = [];
+ var commits = pulseSeriesOf(series, "commitsInWindow");
+ var dead = pulseSeriesOf(series, "deadFileCount");
+ var commitsSpark = sparklineSvg(commits, "Commits-in-window across briefings");
+ var deadSpark = sparklineSvg(dead, "Dead-file count across briefings");
+ if (commitsSpark) {
+ blocks.push('
Activity (' + commits.length + ' snapshots)
' + commitsSpark + '
');
+ }
+ if (deadSpark) {
+ blocks.push('
Dead Files (' + dead.length + ' snapshots)
' + deadSpark + '
');
+ }
+ document.getElementById("pulseTrendBlock").innerHTML = blocks.join("");
+ }
+
function renderActivity(w) {
var section = document.getElementById("activitySection");
if (!w) {
diff --git a/src/domains/git/session-briefing-ui/styles.css b/src/domains/git/session-briefing-ui/styles.css
index 180124e..a7165e5 100644
--- a/src/domains/git/session-briefing-ui/styles.css
+++ b/src/domains/git/session-briefing-ui/styles.css
@@ -495,6 +495,18 @@ h3.sub {
margin: 0 0 14px;
}
+/* Pulse trend charts sit side-by-side where width allows, stack otherwise. */
+#pulseTrendBlock {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 24px;
+}
+
+.pulse-trend {
+ flex: 1 1 240px;
+ min-width: 200px;
+}
+
.risk {
display: inline-block;
padding: 1px 8px;
diff --git a/src/domains/git/session-handler.ts b/src/domains/git/session-handler.ts
index cadf130..c03722c 100644
--- a/src/domains/git/session-handler.ts
+++ b/src/domains/git/session-handler.ts
@@ -72,6 +72,24 @@ function deterministicSummary(agg: SessionBriefing): string {
}
}
+ if (agg.pulse?.deltas && agg.pulse.previousAt) {
+ const d = agg.pulse.deltas;
+ const parts: string[] = [];
+ const fmt = (label: string, v: number | undefined): void => {
+ if (v !== undefined && v !== 0) parts.push(`${label} ${v > 0 ? "+" : ""}${v}`);
+ };
+ fmt("commits-in-window", d.commitsInWindow);
+ fmt("files-touched", d.filesTouched);
+ fmt("dead files", d.deadFileCount);
+ fmt("large files", d.largeFileCount);
+ fmt("dead-code items", d.deadCodeItemCount);
+ fmt("uncommitted", d.uncommittedCount);
+ lines.push(
+ `Pulse since ${agg.pulse.previousAt}: ` +
+ (parts.length > 0 ? parts.join(", ") + "." : "no movement.")
+ );
+ }
+
if (agg.pendingChangeRisk && agg.pendingChangeRisk.totalChanged > 0) {
const p = agg.pendingChangeRisk;
lines.push(
@@ -154,6 +172,7 @@ export function createSessionBriefingHandler(
hygieneSnapshot: agg.hygieneSnapshot,
pendingChangeRisk: agg.pendingChangeRisk,
pendingChangeCompanions: agg.pendingChangeCompanions,
+ pulse: agg.pulse,
},
});
diff --git a/src/domains/git/types.ts b/src/domains/git/types.ts
index e322976..70ffb53 100644
--- a/src/domains/git/types.ts
+++ b/src/domains/git/types.ts
@@ -126,6 +126,37 @@ export interface PendingChangeCompanions {
files: PendingCompanion[];
}
+/** One point of the pulse series — a stored snapshot reduced to chartable fields. */
+export interface PulsePoint {
+ timestampMs: number;
+ uncommittedCount: number;
+ commitsInWindow?: number;
+ deadFileCount?: number;
+}
+
+/**
+ * Longitudinal pulse — how the workspace moved since the previous briefing
+ * (ADR 019). Additive, optional, fail-soft slice (ADR 011 rule): present iff
+ * a pulse store was injected and readable. `deltas` fields are current minus
+ * previous, present only when both sides were measured; `series` is
+ * oldest→newest and includes the current (possibly not-yet-stored) point;
+ * `appended` is false when the store's min-interval throttle suppressed the
+ * write for this briefing.
+ */
+export interface PulseSlice {
+ previousAt?: string;
+ deltas?: {
+ uncommittedCount: number;
+ commitsInWindow?: number;
+ filesTouched?: number;
+ deadFileCount?: number;
+ largeFileCount?: number;
+ deadCodeItemCount?: number;
+ };
+ series: PulsePoint[];
+ appended: boolean;
+}
+
export interface SessionBriefing {
generatedAt: string;
branch: string;
@@ -141,6 +172,7 @@ export interface SessionBriefing {
hygieneSnapshot?: HygieneSnapshot;
pendingChangeRisk?: PendingChangeRisk;
pendingChangeCompanions?: PendingChangeCompanions;
+ pulse?: PulseSlice;
}
export type SessionBriefingReport = SessionBriefing & { summary: string };
diff --git a/src/domains/hygiene/analytics-service.ts b/src/domains/hygiene/analytics-service.ts
index f9c21ec..d175930 100644
--- a/src/domains/hygiene/analytics-service.ts
+++ b/src/domains/hygiene/analytics-service.ts
@@ -16,6 +16,11 @@ import {
} from "./analytics-types";
import { DeadCodeScan } from "../../types";
import { CACHE_SETTINGS, HYGIENE_ANALYTICS_EXCLUDE_PATTERNS } from "../../constants";
+import {
+ ECOSYSTEM_CACHE_DIRS,
+ ECOSYSTEM_ENV_DIRS,
+ ECOSYSTEM_VENDOR_DIRS,
+} from "../../ecosystems";
import { TtlCache } from "../../infrastructure/cache";
import { pathMatchesAny } from "../../infrastructure/glob-match";
import { ignoreFileMtimeMs, readMeridianIgnorePatterns } from "../../security/ignore-store";
@@ -31,19 +36,16 @@ import {
/**
* Heavy dirs we never recurse into (would be expensive). We only check existence
- * and add a single placeholder entry (one stat) so they still show as hygiene targets.
- * Covers: JS/Node, Python, PHP/Ruby, JVM/Gradle, .NET, Terraform, Dart/Flutter,
- * Elixir, Haskell, Clojure.
+ * and add a single placeholder entry (one stat) so they still show as hygiene
+ * targets. Derived from the ecosystem registry (ADR 018) as env∪cache∪vendor —
+ * exactly the dirs HYGIENE_ANALYTICS_EXCLUDE_PATTERNS excludes, so exclusion
+ * and placeholder membership cannot drift. Build-output dirs are absent by
+ * design: the walker recurses them so contents surface as prune candidates.
*/
-const HEAVY_ARTIFACT_DIRS = new Set([
- "node_modules", "venv", ".venv",
- "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs",
- ".yarn", ".pnpm-store",
- "vendor", ".bundle",
- ".gradle", "packages",
- ".terraform", ".dart_tool",
- "deps", "_build",
- ".stack-work", ".cpcache",
+const HEAVY_ARTIFACT_DIRS: ReadonlySet = new Set([
+ ...ECOSYSTEM_ENV_DIRS,
+ ...ECOSYSTEM_CACHE_DIRS,
+ ...ECOSYSTEM_VENDOR_DIRS,
]);
function countLines(filePath: string, ext: string, sizeBytes: number): number {
diff --git a/src/domains/hygiene/analytics-utils.ts b/src/domains/hygiene/analytics-utils.ts
index f2d46fb..466df65 100644
--- a/src/domains/hygiene/analytics-utils.ts
+++ b/src/domains/hygiene/analytics-utils.ts
@@ -13,24 +13,38 @@ import {
TemporalBucket,
TemporalData,
} from "./analytics-types";
+import {
+ ECOSYSTEM_ARTIFACT_EXTS,
+ ECOSYSTEM_BUILD_DIRS,
+ ECOSYSTEM_CACHE_DIRS,
+ ECOSYSTEM_CONFIG_EXTS,
+ ECOSYSTEM_ENV_DIRS,
+ ECOSYSTEM_SOURCE_EXTS,
+ ECOSYSTEM_VENDOR_DIRS,
+} from "../../ecosystems";
// ============================================================================
-// Category mapping
+// Category mapping — ecosystem-specific sets derive from the registry
+// (ADR 018); only the ecosystem-neutral sets are defined here.
// ============================================================================
const MARKDOWN_EXTS = new Set([".md", ".mdx"]);
const LOG_EXTS = new Set([".log"]);
-const CONFIG_EXTS = new Set([".yml", ".yaml", ".json", ".toml", ".ini", ".env"]);
+const CONFIG_EXTS = ECOSYSTEM_CONFIG_EXTS;
const BACKUP_EXTS = new Set([".bak", ".orig", ".swp"]);
const TEMP_EXTS = new Set([".tmp", ".temp"]);
-const SOURCE_EXTS = new Set([".ts", ".js", ".py", ".go", ".rs", ".java", ".rb", ".cs", ".tsx", ".jsx", ".sh", ".bash"]);
+const SOURCE_EXTS = ECOSYSTEM_SOURCE_EXTS;
/** Compiled / generated artifact extensions */
-export const ARTIFACT_EXTS = new Set([".class", ".pyc", ".pyo", ".o", ".obj", ".a", ".so"]);
-/** Directory names that indicate build / cache, venvs, or tool output */
-export const ARTIFACT_DIRS = new Set([
- "target", ".next", ".nuxt", ".parcel-cache",
- "__pycache__", "venv", ".venv",
- ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs",
+export const ARTIFACT_EXTS = ECOSYSTEM_ARTIFACT_EXTS;
+/**
+ * Directory names whose contents categorize as "artifact": environments,
+ * caches, and build outputs. Vendor dirs are deliberately absent — they are
+ * never recursed, so their contents never reach categorize().
+ */
+export const ARTIFACT_DIRS: ReadonlySet = new Set([
+ ...ECOSYSTEM_ENV_DIRS,
+ ...ECOSYSTEM_CACHE_DIRS,
+ ...ECOSYSTEM_BUILD_DIRS,
]);
/** Extensions for which we attempt line counting (text-based only) */
@@ -92,25 +106,17 @@ const COLLECTION_BUCKET_KEYS: readonly CollectionBucket[] = [
];
/**
- * Dir-name → bucket map for the Collections section. Broader than
- * HEAVY_ARTIFACT_DIRS in analytics-service.ts: includes build-output dirs
- * (dist/build/out/target) that the walker DOES recurse into, so they get
- * surfaced as collections via their file paths, not via a placeholder row.
+ * Dir-name → bucket map for the Collections section, derived directly from
+ * the ecosystem registry (ADR 018). Broader than HEAVY_ARTIFACT_DIRS in
+ * analytics-service.ts: includes build-output dirs (dist/build/out/target)
+ * that the walker DOES recurse into, so they get surfaced as collections via
+ * their file paths, not via a placeholder row.
*/
export const COLLECTION_BUCKETS: Record> = {
- envs: new Set(["venv", ".venv", "env"]),
- caches: new Set([
- "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs",
- ".yarn", ".pnpm-store", ".cache",
- ".gradle", ".terraform", ".dart_tool", ".cpcache", ".stack-work", ".parcel-cache",
- ".next", ".nuxt",
- ]),
- buildOutputs: new Set([
- "dist", "build", "out", "target", "_build", "deps", "bundled",
- ]),
- vendoredDeps: new Set([
- "node_modules", "vendor", "packages", ".bundle",
- ]),
+ envs: ECOSYSTEM_ENV_DIRS,
+ caches: ECOSYSTEM_CACHE_DIRS,
+ buildOutputs: ECOSYSTEM_BUILD_DIRS,
+ vendoredDeps: ECOSYSTEM_VENDOR_DIRS,
};
/** Resolve a directory name to its Collections bucket, or null if it's not a known heavy-artifact dir. */
diff --git a/src/domains/hygiene/collection-detector.ts b/src/domains/hygiene/collection-detector.ts
index 37df5b6..0318c1d 100644
--- a/src/domains/hygiene/collection-detector.ts
+++ b/src/domains/hygiene/collection-detector.ts
@@ -21,7 +21,7 @@ import { bucketForDirName } from "./analytics-utils";
const MAX_DEPTH = 3;
/** Skip these dir names entirely during the walk (not collections, not interesting). */
-const SKIP_DIRS = new Set([".git", ".meridian", ".vscode", ".idea"]);
+const SKIP_DIRS = new Set([".git", ".meridian", ".vscode", ".idea", ".settings"]);
export function detectCollections(workspaceRoot: string): CollectionsBreakdown {
const result: CollectionsBreakdown = {
diff --git a/src/domains/hygiene/service.ts b/src/domains/hygiene/service.ts
index 271e5ff..ab217e3 100644
--- a/src/domains/hygiene/service.ts
+++ b/src/domains/hygiene/service.ts
@@ -23,6 +23,8 @@ import { HygieneAnalyzer } from "./analytics-service";
import { DeadCodeAnalyzer } from "./dead-code-analyzer";
import { createShowHygieneAnalyticsHandler } from "./analytics-handler";
import { createImpactAnalysisHandler } from "./impact-analysis-handler";
+import { createStorageHandlers } from "./storage-handler";
+import { RunLog } from "../../infrastructure/run-log";
/**
* Hygiene domain commands.
@@ -32,6 +34,8 @@ export const HYGIENE_COMMANDS: HygieneCommandName[] = [
"hygiene.cleanup",
"hygiene.showAnalytics",
"hygiene.impactAnalysis",
+ "hygiene.storageStatus",
+ "hygiene.pruneStorage",
];
export class HygieneDomainService implements DomainService {
@@ -54,7 +58,8 @@ export class HygieneDomainService implements DomainService {
workspaceProvider: WorkspaceProvider,
logger: Logger,
workspaceRoot?: string,
- generateProseFn?: GenerateProseFn
+ generateProseFn?: GenerateProseFn,
+ runLog?: RunLog
) {
this.logger = logger;
this.workspaceRoot = workspaceRoot;
@@ -62,6 +67,7 @@ export class HygieneDomainService implements DomainService {
this.deadCodeAnalyzer = new DeadCodeAnalyzer(logger);
// Initialize handlers
+ const storage = createStorageHandlers(logger, runLog, workspaceRoot);
this.handlers = {
"hygiene.scan": createScanHandler(workspaceProvider, logger, this.deadCodeAnalyzer, (scan, scannedAt) => {
this.lastScan = { scan, scannedAt };
@@ -69,6 +75,8 @@ export class HygieneDomainService implements DomainService {
"hygiene.cleanup": createCleanupHandler(workspaceProvider, logger),
"hygiene.showAnalytics": createShowHygieneAnalyticsHandler(this.analyzer, this.deadCodeAnalyzer, logger),
"hygiene.impactAnalysis": createImpactAnalysisHandler(logger, generateProseFn),
+ "hygiene.storageStatus": storage.storageStatus,
+ "hygiene.pruneStorage": storage.pruneStorage,
};
}
@@ -140,7 +148,8 @@ export function createHygieneDomain(
workspaceProvider: WorkspaceProvider,
logger: Logger,
workspaceRoot?: string,
- generateProseFn?: GenerateProseFn
+ generateProseFn?: GenerateProseFn,
+ runLog?: RunLog
): HygieneDomainService {
- return new HygieneDomainService(workspaceProvider, logger, workspaceRoot, generateProseFn);
+ return new HygieneDomainService(workspaceProvider, logger, workspaceRoot, generateProseFn, runLog);
}
diff --git a/src/domains/hygiene/storage-handler.ts b/src/domains/hygiene/storage-handler.ts
new file mode 100644
index 0000000..aa8c6fa
--- /dev/null
+++ b/src/domains/hygiene/storage-handler.ts
@@ -0,0 +1,101 @@
+/**
+ * Hygiene Storage Handlers — meridian self-policing surface (ADR 019).
+ *
+ * hygiene.storageStatus — footprint + would-prune preview for Meridian-owned
+ * storage (artifacts, run log, pulse). hygiene.pruneStorage — apply the
+ * retention policy now (artifacts prune + run-log compaction). Thin wrappers
+ * over infrastructure/retention.ts; the preview and the prune share one
+ * planner, so they cannot disagree.
+ *
+ * Both handlers come from one factory sharing a TtlCache: the hygiene tree
+ * dispatches storageStatus on every root rebuild (debounced-watcher driven),
+ * and the status read includes a full run-log line count — the cache bounds
+ * that to once per CACHE_SETTINGS.STORAGE_STATUS_TTL_MS. A successful prune
+ * invalidates, so the post-prune refresh never serves the stale preview.
+ */
+
+import { CommandContext, Handler, Logger, Result, failure, success } from "../../types";
+import {
+ StorageStatus,
+ computeStorageStatus,
+ getRetentionPolicy,
+ pruneArtifacts,
+ PruneOutcome,
+} from "../../infrastructure/retention";
+import { RunLog } from "../../infrastructure/run-log";
+import { INFRASTRUCTURE_ERROR_CODES } from "../../infrastructure/error-codes";
+import { TtlCache } from "../../infrastructure/cache";
+import { CACHE_SETTINGS } from "../../constants";
+
+export interface PruneStorageOutcome {
+ artifacts: PruneOutcome;
+ runLogDropped: number;
+}
+
+export interface StorageHandlers {
+ storageStatus: Handler, StorageStatus>;
+ pruneStorage: Handler, PruneStorageOutcome>;
+}
+
+function resolveRoot(ctx: CommandContext, fallbackRoot?: string): string | undefined {
+ return ctx.workspaceFolders?.[0] ?? fallbackRoot;
+}
+
+function noWorkspace(context: string): Result {
+ return failure({
+ code: INFRASTRUCTURE_ERROR_CODES.WORKSPACE_NOT_FOUND,
+ message: "No workspace folder open — Meridian storage lives per-workspace",
+ details: undefined,
+ context,
+ });
+}
+
+export function createStorageHandlers(
+ logger: Logger,
+ runLog?: RunLog,
+ fallbackRoot?: string
+): StorageHandlers {
+ const statusCache = new TtlCache(CACHE_SETTINGS.STORAGE_STATUS_TTL_MS);
+
+ const storageStatus: StorageHandlers["storageStatus"] = async (ctx) => {
+ const root = resolveRoot(ctx, fallbackRoot);
+ if (!root) return noWorkspace("hygiene.storageStatus");
+
+ const cached = statusCache.get(root);
+ if (cached) return success(cached);
+
+ const result = await computeStorageStatus(root);
+ if (result.kind === "ok") statusCache.set(root, result.value);
+ return result;
+ };
+
+ const pruneStorage: StorageHandlers["pruneStorage"] = async (ctx) => {
+ const root = resolveRoot(ctx, fallbackRoot);
+ if (!root) return noWorkspace("hygiene.pruneStorage");
+
+ const policy = getRetentionPolicy();
+ const artifactsResult = await pruneArtifacts(root, policy, logger);
+ if (artifactsResult.kind === "err") return artifactsResult;
+
+ // Run-log compaction is best-effort: a failure downgrades to a warning
+ // and the artifacts outcome still reports.
+ let runLogDropped = 0;
+ if (runLog) {
+ const compactResult = await runLog.compact(policy.runLogMaxEvents);
+ if (compactResult.kind === "err") {
+ logger.warn("Prune storage: run-log compaction failed", "hygiene.pruneStorage", compactResult.error);
+ } else {
+ runLogDropped = compactResult.value;
+ }
+ }
+
+ statusCache.delete(root);
+ logger.info(
+ `Storage pruned: ${artifactsResult.value.deletedCount} artifact(s), ${runLogDropped} run-log event(s)`,
+ "hygiene.pruneStorage"
+ );
+ return success({ artifacts: artifactsResult.value, runLogDropped });
+ };
+
+ return { storageStatus, pruneStorage };
+}
diff --git a/src/ecosystems.ts b/src/ecosystems.ts
new file mode 100644
index 0000000..26c91b1
--- /dev/null
+++ b/src/ecosystems.ts
@@ -0,0 +1,148 @@
+/**
+ * Ecosystem Registry — single source of truth for language/toolchain
+ * semantics: which directory names are environments, caches, build outputs,
+ * or vendored dependencies, and which file extensions are sources, configs,
+ * or compiled artifacts.
+ *
+ * Every exclusion list, collection bucket, and categorization set that used
+ * to be hand-maintained (and drifted) across constants.ts,
+ * hygiene/analytics-utils.ts, and hygiene/analytics-service.ts is DERIVED
+ * from this registry (ADR 018). Adding ecosystem coverage is one profile
+ * entry here — never an edit to a consumer list.
+ *
+ * Bucket semantics:
+ * - envDirs — local interpreter/runtime environments (never recurse).
+ * - cacheDirs — regenerable tool caches and coverage output (never recurse).
+ * - vendorDirs — fetched third-party dependencies (never recurse).
+ * - buildDirs — compiled/build output. Excluded from the hygiene scan, but
+ * RECURSED by hygiene analytics so contents surface as prune
+ * candidates (see constants.ts HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).
+ *
+ * Deliberately omitted dir names (ADR 018): bare `bin` (legit script dirs are
+ * common; Eclipse/.NET bin output is not worth the false-positive rate);
+ * `packages` (yarn/pnpm monorepos keep first-party source there — silently
+ * blinding the hygiene scan to it costs more than legacy NuGet layouts gain;
+ * NuGet shops can opt out via .meridianignore); and `.mvn` /
+ * `.settings`-style committed project config (IDE metadata lives in
+ * WORKSPACE_EXCLUDE_BASE instead).
+ *
+ * This module imports nothing so any layer — including constants.ts — can
+ * consume it without cycles.
+ */
+
+export interface EcosystemProfile {
+ readonly name: string;
+ readonly envDirs?: readonly string[];
+ readonly cacheDirs?: readonly string[];
+ readonly buildDirs?: readonly string[];
+ readonly vendorDirs?: readonly string[];
+ readonly artifactExts?: readonly string[];
+ readonly sourceExts?: readonly string[];
+ readonly configExts?: readonly string[];
+}
+
+export const ECOSYSTEM_PROFILES: readonly EcosystemProfile[] = [
+ {
+ // Cross-ecosystem conventions that no single toolchain owns.
+ name: "common",
+ cacheDirs: [".cache", "coverage", ".nyc_output"],
+ buildDirs: ["dist", "build", "out", "bundled"],
+ sourceExts: [".sh", ".bash"],
+ configExts: [".json", ".yml", ".yaml", ".env"],
+ },
+ {
+ name: "node",
+ cacheDirs: [".yarn", ".pnpm-store", ".next", ".nuxt", ".parcel-cache"],
+ vendorDirs: ["node_modules"],
+ sourceExts: [".ts", ".tsx", ".js", ".jsx"],
+ },
+ {
+ name: "python",
+ envDirs: ["venv", ".venv", "env"],
+ cacheDirs: ["__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs"],
+ artifactExts: [".pyc", ".pyo"],
+ sourceExts: [".py"],
+ configExts: [".toml", ".ini"],
+ },
+ {
+ // Java / Kotlin / Scala / Groovy — Maven, Gradle, sbt toolchains.
+ name: "jvm",
+ cacheDirs: [".gradle", ".kotlin", ".bloop", ".metals"],
+ buildDirs: ["target"],
+ artifactExts: [".class", ".jar", ".war", ".ear", ".hprof"],
+ sourceExts: [".java", ".kt", ".kts", ".scala", ".groovy"],
+ configExts: [".xml", ".properties", ".gradle"],
+ },
+ {
+ name: "go",
+ vendorDirs: ["vendor"],
+ sourceExts: [".go"],
+ },
+ {
+ // `target` is shared with jvm/Maven — the set union deduplicates.
+ name: "rust",
+ buildDirs: ["target"],
+ sourceExts: [".rs"],
+ },
+ {
+ name: "dotnet",
+ buildDirs: ["obj"],
+ sourceExts: [".cs"],
+ },
+ {
+ name: "ruby",
+ vendorDirs: [".bundle"],
+ sourceExts: [".rb"],
+ },
+ {
+ name: "elixir",
+ buildDirs: ["_build"],
+ vendorDirs: ["deps"],
+ },
+ {
+ name: "native",
+ artifactExts: [".o", ".obj", ".a", ".so"],
+ },
+ { name: "terraform", cacheDirs: [".terraform"] },
+ { name: "dart", cacheDirs: [".dart_tool"] },
+ { name: "haskell", cacheDirs: [".stack-work"] },
+ { name: "clojure", cacheDirs: [".cpcache"] },
+];
+
+function unionOf(pick: (p: EcosystemProfile) => readonly string[] | undefined): ReadonlySet {
+ const out = new Set();
+ for (const profile of ECOSYSTEM_PROFILES) {
+ for (const entry of pick(profile) ?? []) out.add(entry);
+ }
+ return out;
+}
+
+export const ECOSYSTEM_ENV_DIRS = unionOf((p) => p.envDirs);
+export const ECOSYSTEM_CACHE_DIRS = unionOf((p) => p.cacheDirs);
+export const ECOSYSTEM_BUILD_DIRS = unionOf((p) => p.buildDirs);
+export const ECOSYSTEM_VENDOR_DIRS = unionOf((p) => p.vendorDirs);
+
+export const ECOSYSTEM_ARTIFACT_EXTS = unionOf((p) => p.artifactExts);
+export const ECOSYSTEM_SOURCE_EXTS = unionOf((p) => p.sourceExts);
+export const ECOSYSTEM_CONFIG_EXTS = unionOf((p) => p.configExts);
+
+// Exclusion globs for a group of dir-name sets, sorted for determinism.
+// Two forms exist because consumers match different path kinds:
+// - "descendants" — only "**//**". For file-path matchers (the hygiene
+// scan): excludes contents without accidentally excluding a FILE that
+// happens to be named "dist" or "deps".
+// - "both" — adds the dir-path form "**/". For the analytics walker,
+// which pattern-matches directory paths themselves to stop recursion and
+// emit the single placeholder row.
+export function dirExcludeGlobs(
+ sets: ReadonlyArray>,
+ form: "descendants" | "both" = "descendants"
+): readonly string[] {
+ const names = new Set();
+ for (const set of sets) for (const name of set) names.add(name);
+ return [...names]
+ .sort()
+ .flatMap((name) =>
+ form === "both" ? [`**/${name}/**`, `**/${name}`] : [`**/${name}/**`]
+ );
+}
diff --git a/src/infrastructure/error-codes.ts b/src/infrastructure/error-codes.ts
index f4cd2da..9e2fdf4 100644
--- a/src/infrastructure/error-codes.ts
+++ b/src/infrastructure/error-codes.ts
@@ -70,6 +70,10 @@ export const INFRASTRUCTURE_ERROR_CODES = {
RUN_LOG_READ_ERROR: "RUN_LOG_READ_ERROR",
RUN_LOG_PARSE_ERROR: "RUN_LOG_PARSE_ERROR",
RUN_LOG_VERSION_UNSUPPORTED: "RUN_LOG_VERSION_UNSUPPORTED",
+ PULSE_WRITE_ERROR: "PULSE_WRITE_ERROR",
+ PULSE_READ_ERROR: "PULSE_READ_ERROR",
+ PULSE_VERSION_UNSUPPORTED: "PULSE_VERSION_UNSUPPORTED",
+ RETENTION_ERROR: "RETENTION_ERROR",
} as const;
// ============================================================================
diff --git a/src/infrastructure/jsonl-tail.ts b/src/infrastructure/jsonl-tail.ts
new file mode 100644
index 0000000..ebfd1ab
--- /dev/null
+++ b/src/infrastructure/jsonl-tail.ts
@@ -0,0 +1,39 @@
+/**
+ * JSONL tail compaction — keep the newest N lines of a line-delimited file,
+ * atomically (write tmp sibling, rename over). Operates on raw lines and
+ * never parses JSON, so it is schema-agnostic and safe for version-pinned
+ * stores (run log ADR 009, pulse store ADR 019): a malformed or
+ * future-versioned line is preserved verbatim, never interpreted.
+ *
+ * Shared by FileRunLog.compact() and FilePulseStore's append-time cap.
+ */
+
+import { promises as fs } from "node:fs";
+
+/**
+ * Rewrite `filePath` to its newest `maxLines` lines. Returns the number of
+ * lines dropped (0 when under the cap or the file is missing). `maxLines <= 0`
+ * is a no-op — retention disabled. Not concurrency-safe on its own: callers
+ * that also append (run log, pulse store) must invoke this inside their
+ * write queue.
+ */
+export async function compactJsonlTail(filePath: string, maxLines: number): Promise {
+ if (maxLines <= 0) return 0;
+
+ let raw: string;
+ try {
+ raw = await fs.readFile(filePath, "utf8");
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0;
+ throw err;
+ }
+
+ const lines = raw.split("\n").filter((line) => line.trim().length > 0);
+ if (lines.length <= maxLines) return 0;
+
+ const kept = lines.slice(-maxLines);
+ const tmpPath = `${filePath}.tmp`;
+ await fs.writeFile(tmpPath, kept.join("\n") + "\n", "utf8");
+ await fs.rename(tmpPath, filePath);
+ return lines.length - kept.length;
+}
diff --git a/src/infrastructure/pulse-store.ts b/src/infrastructure/pulse-store.ts
new file mode 100644
index 0000000..ca0d6fa
--- /dev/null
+++ b/src/infrastructure/pulse-store.ts
@@ -0,0 +1,169 @@
+/**
+ * Pulse store — append-only, versioned JSONL history of per-session workspace
+ * snapshots, the longitudinal substrate behind the session briefing's pulse
+ * slice (ADR 019). Modeled on FileRunLog (ADR 009): serialized writes through
+ * a queue, schema-version guard on both append and read.
+ *
+ * Location: `.meridian/pulse/pulse.v1.jsonl` (ADR 014 dotdir). The dir is
+ * self-ignoring (a `.gitignore` containing `*` is dropped on first write, the
+ * artifacts-dir pattern) — pulse data is host-local insight; sharing an
+ * append-only JSONL through git would merge-conflict across machines.
+ */
+
+import { promises as fs } from "node:fs";
+import * as fsSync from "node:fs";
+import path from "node:path";
+import { AppError, Logger, Result, failure, success } from "../types";
+import { MERIDIAN_DIR, MERIDIAN_PULSE_DIR, PULSE } from "../constants";
+import { INFRASTRUCTURE_ERROR_CODES } from "./error-codes";
+import { compactJsonlTail } from "./jsonl-tail";
+
+export const PULSE_SCHEMA_VERSION = 1 as const;
+
+/**
+ * One stored snapshot. Optional fields mirror the briefing's fail-soft
+ * peripheral slices: absent when the source was unavailable at capture time,
+ * never zero-filled (a 0 means "measured as zero", absence means "not
+ * measured").
+ */
+export interface PulseSnapshotV1 {
+ schemaVersion: typeof PULSE_SCHEMA_VERSION;
+ timestampMs: number;
+ branch: string;
+ uncommittedCount: number;
+ commitsInWindow?: number;
+ filesTouched?: number;
+ deadFileCount?: number;
+ largeFileCount?: number;
+ logFileCount?: number;
+ deadCodeItemCount?: number;
+ hotspotCount?: number;
+}
+
+export function isSupportedPulseVersion(version: unknown): version is typeof PULSE_SCHEMA_VERSION {
+ return version === PULSE_SCHEMA_VERSION;
+}
+
+export interface PulseStore {
+ /** Append one snapshot; tail-compacts past PULSE.MAX_SNAPSHOTS. */
+ append(snapshot: PulseSnapshotV1): Promise>;
+ /** Newest `limit` snapshots, oldest→newest order. */
+ readLatest(limit: number): Promise>;
+}
+
+function makeError(code: string, message: string, details: unknown, context: string): AppError {
+ return { code, message, details, context };
+}
+
+export class FilePulseStore implements PulseStore {
+ private readonly dirPath: string;
+ private readonly filePath: string;
+ private writeQueue: Promise = Promise.resolve();
+
+ constructor(workspaceRoot: string, private readonly logger: Logger) {
+ this.dirPath = path.join(workspaceRoot, MERIDIAN_DIR, MERIDIAN_PULSE_DIR);
+ this.filePath = path.join(this.dirPath, `pulse.v${PULSE_SCHEMA_VERSION}.jsonl`);
+ }
+
+ async append(snapshot: PulseSnapshotV1): Promise> {
+ if (!isSupportedPulseVersion(snapshot.schemaVersion)) {
+ return failure(
+ makeError(
+ INFRASTRUCTURE_ERROR_CODES.PULSE_VERSION_UNSUPPORTED,
+ `Pulse store: unsupported schemaVersion ${String(snapshot.schemaVersion)} (append rejected)`,
+ snapshot,
+ "FilePulseStore.append"
+ )
+ );
+ }
+ try {
+ await this.enqueue(async () => {
+ await fs.mkdir(this.dirPath, { recursive: true });
+ this.writeSelfIgnore();
+ await fs.appendFile(this.filePath, JSON.stringify(snapshot) + "\n", "utf8");
+ await compactJsonlTail(this.filePath, PULSE.MAX_SNAPSHOTS);
+ });
+ return success(void 0);
+ } catch (err) {
+ return failure(
+ makeError(
+ INFRASTRUCTURE_ERROR_CODES.PULSE_WRITE_ERROR,
+ `Pulse store: append failed (${this.filePath})`,
+ err,
+ "FilePulseStore.append"
+ )
+ );
+ }
+ }
+
+ async readLatest(limit: number): Promise> {
+ const safeLimit = Math.max(0, Math.trunc(limit));
+ if (safeLimit === 0) return success([]);
+
+ let raw: string;
+ try {
+ raw = await fs.readFile(this.filePath, "utf8");
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return success([]);
+ return failure(
+ makeError(
+ INFRASTRUCTURE_ERROR_CODES.PULSE_READ_ERROR,
+ `Pulse store: read failed (${this.filePath})`,
+ err,
+ "FilePulseStore.readLatest"
+ )
+ );
+ }
+
+ const snapshots: PulseSnapshotV1[] = [];
+ for (const line of raw.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ // Tolerate a torn/corrupt line (e.g. crash mid-append): drop it with a
+ // warning rather than fail the whole history — pulse is a peripheral,
+ // fail-soft source by contract (unlike the run log's strict reader).
+ this.logger.warn("Pulse store: skipping malformed line", "FilePulseStore.readLatest");
+ continue;
+ }
+ if (
+ typeof parsed !== "object" || parsed === null ||
+ !isSupportedPulseVersion((parsed as { schemaVersion?: unknown }).schemaVersion)
+ ) {
+ this.logger.warn("Pulse store: skipping unsupported-version line", "FilePulseStore.readLatest");
+ continue;
+ }
+ snapshots.push(parsed as PulseSnapshotV1);
+ }
+ return success(snapshots.slice(-safeLimit));
+ }
+
+ /**
+ * Idempotently drop the self-ignoring `.gitignore` (`*`). Sync + swallowed:
+ * the write is tiny, one-shot, and non-fatal (matches the artifacts-dir
+ * writer in webview-provider.ts).
+ */
+ private writeSelfIgnore(): void {
+ try {
+ const gitignore = path.join(this.dirPath, ".gitignore");
+ if (!fsSync.existsSync(gitignore)) fsSync.writeFileSync(gitignore, "*\n", "utf-8");
+ } catch (e) {
+ this.logger.warn(`Pulse store: self-ignore write failed: ${String(e)}`, "FilePulseStore");
+ }
+ }
+
+ private enqueue(task: () => Promise): Promise {
+ const run = this.writeQueue.then(task);
+ this.writeQueue = run.catch(() => {
+ // Error propagates to append()'s caller; the queue itself must not stall.
+ });
+ return run;
+ }
+}
+
+export function createPulseStore(workspaceRoot: string, logger: Logger): PulseStore {
+ return new FilePulseStore(workspaceRoot, logger);
+}
diff --git a/src/infrastructure/retention.ts b/src/infrastructure/retention.ts
new file mode 100644
index 0000000..01fd6ed
--- /dev/null
+++ b/src/infrastructure/retention.ts
@@ -0,0 +1,253 @@
+/**
+ * Retention engine (ADR 019) — self-policing for Meridian-owned storage.
+ *
+ * Scope is exactly three Meridian-owned locations, never anything else:
+ * 1. `.meridian/artifacts/` — exported reports (count + age caps)
+ * 2. `.vscode/meridian/run-log.v1.jsonl` — via RunLog.compact (event cap)
+ * 3. `.meridian/pulse/` — self-capping (pulse-store), status only
+ *
+ * Enforcement is lazy (ADR 014 doctrine — no timers, no watchers): once at
+ * activation (fire-and-forget) and after each artifacts quick-save. The
+ * planning step is a pure function so the storage surface's "would prune"
+ * preview and the actual prune can never disagree.
+ */
+
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { AppError, Logger, Result, failure, success } from "../types";
+import { MERIDIAN_ARTIFACTS_DIR, MERIDIAN_DIR, MERIDIAN_PULSE_DIR, PULSE } from "../constants";
+import { INFRASTRUCTURE_ERROR_CODES } from "./error-codes";
+import { readSetting } from "./settings";
+import { RunLog, RUN_LOG_RELATIVE_PATH } from "./run-log";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+// ============================================================================
+// Policy
+// ============================================================================
+
+export interface RetentionPolicy {
+ /** Keep at most this many artifact files; 0 disables the count rule. */
+ artifactsMaxCount: number;
+ /** Prune artifact files older than this many days; 0 disables the age rule. */
+ artifactsMaxAgeDays: number;
+ /** Compact the run log to its newest N events; 0 disables. */
+ runLogMaxEvents: number;
+}
+
+/** Resolve the live policy through the ADR 013 settings chokepoint. */
+export function getRetentionPolicy(): RetentionPolicy {
+ return {
+ artifactsMaxCount: Math.max(0, readSetting("retention.artifacts.maxCount")),
+ artifactsMaxAgeDays: Math.max(0, readSetting("retention.artifacts.maxAgeDays")),
+ runLogMaxEvents: Math.max(0, readSetting("retention.runLog.maxEvents")),
+ };
+}
+
+// ============================================================================
+// Planning (pure)
+// ============================================================================
+
+export interface ArtifactFileInfo {
+ name: string;
+ mtimeMs: number;
+ sizeBytes: number;
+}
+
+/**
+ * Decide which artifact files the policy removes. Pure. Age rule first, then
+ * the count cap on survivors (oldest pruned first); newest files always win.
+ */
+export function planArtifactPrune(
+ files: ReadonlyArray,
+ policy: Pick,
+ nowMs: number
+): { keep: ArtifactFileInfo[]; prune: ArtifactFileInfo[] } {
+ const byNewest = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs);
+ const prune: ArtifactFileInfo[] = [];
+ const keep: ArtifactFileInfo[] = [];
+
+ for (const file of byNewest) {
+ const tooOld =
+ policy.artifactsMaxAgeDays > 0 &&
+ nowMs - file.mtimeMs > policy.artifactsMaxAgeDays * DAY_MS;
+ const overCount = policy.artifactsMaxCount > 0 && keep.length >= policy.artifactsMaxCount;
+ if (tooOld || overCount) {
+ prune.push(file);
+ } else {
+ keep.push(file);
+ }
+ }
+ return { keep, prune };
+}
+
+// ============================================================================
+// Filesystem inventory
+// ============================================================================
+
+function artifactsDirPath(workspaceRoot: string): string {
+ return path.join(workspaceRoot, MERIDIAN_DIR, MERIDIAN_ARTIFACTS_DIR);
+}
+
+/**
+ * Inventory the artifacts dir: regular files only, excluding the self-ignore
+ * `.gitignore` (never a prune target). Missing dir → empty inventory.
+ */
+export async function listArtifacts(workspaceRoot: string): Promise {
+ const dir = artifactsDirPath(workspaceRoot);
+ let entries: import("node:fs").Dirent[];
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
+ throw err;
+ }
+
+ const files: ArtifactFileInfo[] = [];
+ for (const entry of entries) {
+ if (!entry.isFile() || entry.name === ".gitignore") continue;
+ try {
+ const stat = await fs.stat(path.join(dir, entry.name));
+ files.push({ name: entry.name, mtimeMs: stat.mtimeMs, sizeBytes: stat.size });
+ } catch {
+ // Raced deletion — skip.
+ }
+ }
+ return files;
+}
+
+// ============================================================================
+// Enforcement
+// ============================================================================
+
+export interface PruneOutcome {
+ deletedCount: number;
+ freedBytes: number;
+}
+
+function makeError(message: string, details: unknown, context: string): AppError {
+ return { code: INFRASTRUCTURE_ERROR_CODES.RETENTION_ERROR, message, details, context };
+}
+
+/**
+ * Apply the artifact policy: plan, then unlink. Only basenames returned by
+ * listArtifacts are ever joined to the artifacts dir — no external input
+ * reaches a path. A single failed unlink is logged and skipped, not fatal.
+ */
+export async function pruneArtifacts(
+ workspaceRoot: string,
+ policy: Pick,
+ logger: Logger
+): Promise> {
+ try {
+ const files = await listArtifacts(workspaceRoot);
+ const { prune } = planArtifactPrune(files, policy, Date.now());
+ const dir = artifactsDirPath(workspaceRoot);
+
+ let deletedCount = 0;
+ let freedBytes = 0;
+ for (const file of prune) {
+ try {
+ await fs.unlink(path.join(dir, file.name));
+ deletedCount += 1;
+ freedBytes += file.sizeBytes;
+ } catch (err) {
+ logger.warn(
+ `Retention: failed to prune artifact ${file.name}: ${String(err)}`,
+ "pruneArtifacts"
+ );
+ }
+ }
+ if (deletedCount > 0) {
+ logger.info(
+ `Retention: pruned ${deletedCount} artifact(s), freed ${freedBytes} bytes`,
+ "pruneArtifacts"
+ );
+ }
+ return success({ deletedCount, freedBytes });
+ } catch (err) {
+ return failure(makeError("Artifact prune failed", err, "pruneArtifacts"));
+ }
+}
+
+/**
+ * Activation-time retention pass: artifacts prune + run-log compaction.
+ * Both legs fail-soft with a warning — retention must never block activation.
+ */
+export async function runActivationRetention(
+ workspaceRoot: string,
+ runLog: RunLog,
+ logger: Logger
+): Promise {
+ const policy = getRetentionPolicy();
+
+ const pruneResult = await pruneArtifacts(workspaceRoot, policy, logger);
+ if (pruneResult.kind === "err") {
+ logger.warn("Retention: activation artifact prune failed", "runActivationRetention", pruneResult.error);
+ }
+
+ const compactResult = await runLog.compact(policy.runLogMaxEvents);
+ if (compactResult.kind === "err") {
+ logger.warn("Retention: run-log compaction failed", "runActivationRetention", compactResult.error);
+ } else if (compactResult.value > 0) {
+ logger.info(`Retention: run log compacted (${compactResult.value} events dropped)`, "runActivationRetention");
+ }
+}
+
+// ============================================================================
+// Storage status (the storage surface's data source)
+// ============================================================================
+
+export interface StorageStatus {
+ artifacts: {
+ fileCount: number;
+ totalBytes: number;
+ oldestMtimeMs: number | null;
+ /** Dry-run of the CURRENT policy — shares planArtifactPrune with the real prune. */
+ wouldPruneCount: number;
+ wouldPruneBytes: number;
+ };
+ runLog: { sizeBytes: number; lineCount: number };
+ pulse: { sizeBytes: number; snapshotCount: number; maxSnapshots: number };
+ policy: RetentionPolicy;
+}
+
+async function fileLineStats(filePath: string): Promise<{ sizeBytes: number; lineCount: number }> {
+ try {
+ const raw = await fs.readFile(filePath, "utf8");
+ return {
+ sizeBytes: Buffer.byteLength(raw, "utf8"),
+ lineCount: raw.split("\n").filter((l) => l.trim().length > 0).length,
+ };
+ } catch {
+ return { sizeBytes: 0, lineCount: 0 };
+ }
+}
+
+export async function computeStorageStatus(workspaceRoot: string): Promise> {
+ try {
+ const policy = getRetentionPolicy();
+ const files = await listArtifacts(workspaceRoot);
+ const { prune } = planArtifactPrune(files, policy, Date.now());
+
+ const runLogStats = await fileLineStats(path.join(workspaceRoot, RUN_LOG_RELATIVE_PATH));
+ const pulseStats = await fileLineStats(
+ path.join(workspaceRoot, MERIDIAN_DIR, MERIDIAN_PULSE_DIR, "pulse.v1.jsonl")
+ );
+
+ return success({
+ artifacts: {
+ fileCount: files.length,
+ totalBytes: files.reduce((sum, f) => sum + f.sizeBytes, 0),
+ oldestMtimeMs: files.length > 0 ? Math.min(...files.map((f) => f.mtimeMs)) : null,
+ wouldPruneCount: prune.length,
+ wouldPruneBytes: prune.reduce((sum, f) => sum + f.sizeBytes, 0),
+ },
+ runLog: runLogStats,
+ pulse: { ...pulseStats, snapshotCount: pulseStats.lineCount, maxSnapshots: PULSE.MAX_SNAPSHOTS },
+ policy,
+ });
+ } catch (err) {
+ return failure(makeError("Storage status failed", err, "computeStorageStatus"));
+ }
+}
diff --git a/src/infrastructure/run-log.ts b/src/infrastructure/run-log.ts
index 15ea6cd..20ec1e5 100644
--- a/src/infrastructure/run-log.ts
+++ b/src/infrastructure/run-log.ts
@@ -16,8 +16,10 @@ import {
success,
} from "../types";
import { INFRASTRUCTURE_ERROR_CODES } from "./error-codes";
+import { compactJsonlTail } from "./jsonl-tail";
-const RUN_LOG_RELATIVE_PATH = ".vscode/meridian/run-log.v1.jsonl";
+/** Host-runtime run-log path, relative to the workspace root (ADR 009/014). */
+export const RUN_LOG_RELATIVE_PATH = ".vscode/meridian/run-log.v1.jsonl";
export function createRunEventId(): string {
return randomUUID();
@@ -32,6 +34,13 @@ export interface RunLog {
appendMany(events: RunEventV1[]): Promise>;
readByRunId(runId: string): Promise>;
readLatest(limit: number): Promise>;
+ /**
+ * Retention (ADR 019): keep only the newest `maxEvents` lines, atomically.
+ * `maxEvents <= 0` disables. Resolves the number of lines dropped. Runs
+ * inside the write queue so it cannot race an in-flight append. Raw-line
+ * tail-keep — never parses, so the v1 schema pinning is untouched.
+ */
+ compact(maxEvents: number): Promise>;
}
function makeError(
@@ -100,6 +109,29 @@ export class FileRunLog implements RunLog {
return success(all.value.slice(-safeLimit));
}
+ async compact(maxEvents: number): Promise> {
+ try {
+ let dropped = 0;
+ const task = this.writeQueue.then(async () => {
+ dropped = await compactJsonlTail(this.filePath, maxEvents);
+ });
+ this.writeQueue = task.catch(() => {
+ // Error propagates to this caller; the queue itself must not stall.
+ });
+ await task;
+ return success(dropped);
+ } catch (err) {
+ return failure(
+ makeError(
+ INFRASTRUCTURE_ERROR_CODES.RUN_LOG_WRITE_ERROR,
+ `Run log: compaction failed (${this.filePath})`,
+ err,
+ "FileRunLog.compact"
+ )
+ );
+ }
+ }
+
private async enqueueWrite(payload: string): Promise {
const writeTask = this.writeQueue.then(async () => {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
diff --git a/src/infrastructure/settings.ts b/src/infrastructure/settings.ts
index 91978cf..3d45c47 100644
--- a/src/infrastructure/settings.ts
+++ b/src/infrastructure/settings.ts
@@ -30,6 +30,9 @@ export const SETTING_DEFAULTS = {
"hygiene.prune.maxSizeMB": 1 as number,
"hygiene.prune.minLineCount": 0 as number,
"hygiene.prune.categories": ["backup", "temp", "log", "artifact"] as readonly string[],
+ "retention.artifacts.maxCount": 50 as number,
+ "retention.artifacts.maxAgeDays": 30 as number,
+ "retention.runLog.maxEvents": 5000 as number,
"sessionBriefing.autoLaunch": false as boolean,
"startup.enableFileWatchers": true as boolean,
} as const;
diff --git a/src/infrastructure/webview-provider.ts b/src/infrastructure/webview-provider.ts
index 206f39d..392ccb7 100644
--- a/src/infrastructure/webview-provider.ts
+++ b/src/infrastructure/webview-provider.ts
@@ -12,6 +12,21 @@ import { appendIgnorePattern } from "../security/ignore-store";
import { REPORT_LABELS, reportCsvHeader } from "../report-labels";
import { MERIDIAN_DIR, MERIDIAN_ARTIFACTS_DIR } from "../constants";
import type { ReportOpenArg } from "../ui/tree-providers/reports-tree-provider";
+import type { Logger } from "../types";
+import { getRetentionPolicy, pruneArtifacts } from "./retention";
+
+/**
+ * Console-backed Logger for the fire-and-forget retention call — this module
+ * predates Logger injection and reports via the console (see handleError);
+ * the adapter keeps that convention rather than churning three provider
+ * constructors for one background call.
+ */
+const consoleLoggerAdapter: Logger = {
+ debug: () => { /* retention emits no debug output here */ },
+ info: (message) => console.log(`[Meridian] ${message}`),
+ warn: (message) => console.warn(`[Meridian] ${message}`),
+ error: (message) => console.error(`[Meridian] ${message}`),
+};
/**
* Optional hook a provider can opt into for the webview right-click "Ignore"
@@ -259,6 +274,12 @@ export abstract class BaseWebviewProvider {
this.handleError("Quick-save failed", e);
return;
}
+ // Retention (ADR 019): each new export lazily re-enforces the artifacts
+ // policy. Fire-and-forget — a prune failure never disturbs the save UX.
+ const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+ if (workspaceRoot) {
+ void pruneArtifacts(workspaceRoot, getRetentionPolicy(), consoleLoggerAdapter);
+ }
const rel = path.join(MERIDIAN_DIR, MERIDIAN_ARTIFACTS_DIR, fileName);
vscode.window.showInformationMessage(`Saved to ${rel}`, "Reveal").then((choice) => {
if (choice === "Reveal") void vscode.commands.executeCommand("revealInExplorer", target);
diff --git a/src/main.ts b/src/main.ts
index 29956e3..4ee7c24 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -23,6 +23,8 @@ import { generateProse } from "./infrastructure/prose-generator";
import { readSetting } from "./infrastructure/settings";
import { getPruneConfig } from "./domains/hygiene/prune-config";
import { createRunLog } from "./infrastructure/run-log";
+import { createPulseStore } from "./infrastructure/pulse-store";
+import { runActivationRetention } from "./infrastructure/retention";
import { migrateLegacyIgnoreFile } from "./infrastructure/dotdir-migration";
import { isSensitiveLoggingEnabled, sanitizeForLogs } from "./security/operation-policy";
@@ -71,6 +73,9 @@ export async function activate(context: vscode.ExtensionContext): Promise
const gitProvider = createGitProvider(workspaceRoot);
const workspaceProvider = createWorkspaceProvider(workspaceRoot, logger);
const runLog = createRunLog(workspaceRoot, logger);
+ // Pulse history writes into .meridian/ — only when a real workspace folder
+ // is open, never inside an arbitrary cwd (same guard as the ADR 014 migration).
+ const pulseStore = workspaceFolder ? createPulseStore(workspaceFolder, logger) : undefined;
// ── Router + middleware ─────────────────────────────────────────────
// security.logging.sensitive (default "redact"): error text is redacted
@@ -84,11 +89,12 @@ export async function activate(context: vscode.ExtensionContext): Promise
// ── Domain registration ─────────────────────────────────────────────
// Pass the raw (possibly undefined) folder, not the cwd fallback: the
// hygiene background scan must never crawl an arbitrary cwd when no
- // workspace is open. The service skips its timer when root is undefined.
- const hygieneDomain = createHygieneDomain(workspaceProvider, logger, workspaceFolder, generateProse);
+ // workspace is open. The service skips its timer when root is undefined —
+ // and the storage handlers then fail with WORKSPACE_NOT_FOUND (ADR 019).
+ const hygieneDomain = createHygieneDomain(workspaceProvider, logger, workspaceFolder, generateProse, runLog);
const gitDomain = createGitDomain(
gitProvider, logger, workspaceRoot, generateProse,
- runLog, () => hygieneDomain.getLastScan()
+ runLog, () => hygieneDomain.getLastScan(), pulseStore
);
router.registerDomain(gitDomain);
router.registerDomain(hygieneDomain);
@@ -145,6 +151,12 @@ export async function activate(context: vscode.ExtensionContext): Promise
// ── Finalize ────────────────────────────────────────────────────────
statusBar.update();
+ // Retention (ADR 019): lazy, fire-and-forget self-policing of Meridian-owned
+ // storage. Only with a real workspace folder — never prune inside a bare cwd.
+ if (workspaceFolder) {
+ void runActivationRetention(workspaceFolder, runLog, logger);
+ }
+
// Auto-launch session briefing if configured
const autoLaunch = readSetting("sessionBriefing.autoLaunch");
if (autoLaunch) {
diff --git a/src/presentation/specialized-commands.ts b/src/presentation/specialized-commands.ts
index 3aaf858..d2b5703 100644
--- a/src/presentation/specialized-commands.ts
+++ b/src/presentation/specialized-commands.ts
@@ -15,6 +15,8 @@ import { HygieneTreeProvider } from "../ui/tree-providers/hygiene-tree-provider"
import { copyWithPolicy } from "../security/operation-policy";
import { appendIgnorePattern } from "../security/ignore-store";
import type { ImpactAnalysisResult } from "../domains/hygiene/impact-analysis-handler";
+import type { PruneStorageOutcome } from "../domains/hygiene/storage-handler";
+import type { StorageStatus } from "../infrastructure/retention";
const HR = "─".repeat(UI_SETTINGS.OUTPUT_HR_LENGTH);
@@ -75,6 +77,59 @@ export function registerSpecializedCommands(
);
hygieneTree.refresh();
}),
+
+ // ── hygiene.pruneStorage (ADR 019) — status → confirm → prune → toast ──
+ vscode.commands.registerCommand("meridian.hygiene.pruneStorage", async () => {
+ const freshCtx = getCommandContext();
+
+ const statusResult = await router.dispatch(
+ { name: "hygiene.storageStatus", params: {} }, freshCtx
+ );
+ if (statusResult.kind === "err") {
+ vscode.window.showErrorMessage(`Storage status failed: ${statusResult.error.message}`);
+ return;
+ }
+ const status = statusResult.value as StorageStatus;
+ const overCap = status.policy.runLogMaxEvents > 0
+ ? Math.max(0, status.runLog.lineCount - status.policy.runLogMaxEvents)
+ : 0;
+
+ if (status.artifacts.wouldPruneCount === 0 && overCap === 0) {
+ vscode.window.showInformationMessage(
+ "Meridian storage is within policy — nothing to prune."
+ );
+ return;
+ }
+
+ const mb = (status.artifacts.wouldPruneBytes / (1024 * 1024)).toFixed(2);
+ const parts: string[] = [];
+ if (status.artifacts.wouldPruneCount > 0) {
+ parts.push(`${status.artifacts.wouldPruneCount} exported report(s) (${mb} MB)`);
+ }
+ if (overCap > 0) {
+ parts.push(`${overCap} run-log event(s)`);
+ }
+ const confirm = await vscode.window.showWarningMessage(
+ `Prune Meridian storage? This removes ${parts.join(" and ")} per the current retention settings.`,
+ { modal: true }, "Prune"
+ );
+ if (confirm !== "Prune") return;
+
+ const result = await router.dispatch(
+ { name: "hygiene.pruneStorage", params: {} }, freshCtx
+ );
+ if (result.kind === "ok") {
+ const outcome = result.value as PruneStorageOutcome;
+ const freedMb = (outcome.artifacts.freedBytes / (1024 * 1024)).toFixed(2);
+ vscode.window.showInformationMessage(
+ `Pruned ${outcome.artifacts.deletedCount} report(s) (${freedMb} MB), ` +
+ `compacted ${outcome.runLogDropped} run-log event(s).`
+ );
+ hygieneTree.refresh();
+ } else {
+ vscode.window.showErrorMessage(`Prune failed: ${result.error.message}`);
+ }
+ }),
);
// ── hygiene.impactAnalysis ────────────────────────────────────────────
diff --git a/src/types.ts b/src/types.ts
index b988b35..4fbe543 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -54,7 +54,13 @@ export type GitCommandName =
| "git.commit"
| "git.showAnalytics"
| "git.sessionBriefing";
-export type HygieneCommandName = "hygiene.scan" | "hygiene.cleanup" | "hygiene.showAnalytics" | "hygiene.impactAnalysis";
+export type HygieneCommandName =
+ | "hygiene.scan"
+ | "hygiene.cleanup"
+ | "hygiene.showAnalytics"
+ | "hygiene.impactAnalysis"
+ | "hygiene.storageStatus"
+ | "hygiene.pruneStorage";
// ============================================================================
// Handler Interface (Aiogram-style Router Pattern)
diff --git a/src/ui/tree-providers/hygiene-tree-provider.ts b/src/ui/tree-providers/hygiene-tree-provider.ts
index c5ea197..6562bd4 100644
--- a/src/ui/tree-providers/hygiene-tree-provider.ts
+++ b/src/ui/tree-providers/hygiene-tree-provider.ts
@@ -14,13 +14,15 @@
import * as vscode from "vscode";
import { Command, CommandContext, DeadCodeItem, Logger, Result, WorkspaceScan } from "../../types";
import { ImpactAnalysisResult } from "../../domains/hygiene/impact-analysis-handler";
+import { StorageStatus } from "../../infrastructure/retention";
type Dispatcher = (cmd: Command, ctx: CommandContext) => Promise>;
type HygieneItemKind =
| "category" | "file" | "markdownFile" | "deadCodeFile" | "deadCodeIssue"
| "impactCategory" | "impactTarget" | "impactMetricGroup" | "impactFile"
- | "collectionDir" | "report";
+ | "collectionDir" | "report"
+ | "storageSection" | "storageInfo";
class HygieneTreeItem extends vscode.TreeItem {
constructor(
@@ -226,10 +228,35 @@ export class HygieneTreeProvider implements vscode.TreeDataProvider (bytes / (1024 * 1024)).toFixed(2);
+ const kb = (bytes: number): string => (bytes / 1024).toFixed(1);
+
+ const rows: HygieneTreeItem[] = [];
+
+ const a = status.artifacts;
+ const oldest =
+ a.oldestMtimeMs !== null
+ ? ` · oldest ${Math.floor((Date.now() - a.oldestMtimeMs) / 86_400_000)}d`
+ : "";
+ const prunable = a.wouldPruneCount > 0 ? ` · ${a.wouldPruneCount} prunable` : "";
+ const artifactsRow = new HygieneTreeItem(
+ "Exported Reports",
+ "storageInfo",
+ [],
+ vscode.TreeItemCollapsibleState.None,
+ `${a.fileCount} files · ${mb(a.totalBytes)} MB${oldest}${prunable}`
+ );
+ artifactsRow.iconPath = new vscode.ThemeIcon("files");
+ artifactsRow.tooltip =
+ `.meridian/artifacts/ — ${a.fileCount} files, ${mb(a.totalBytes)} MB.` +
+ (a.wouldPruneCount > 0
+ ? ` Current policy would prune ${a.wouldPruneCount} file(s) (${mb(a.wouldPruneBytes)} MB).`
+ : " Nothing prunable under the current policy.");
+ rows.push(artifactsRow);
+
+ const overCap =
+ status.policy.runLogMaxEvents > 0
+ ? Math.max(0, status.runLog.lineCount - status.policy.runLogMaxEvents)
+ : 0;
+ const runLogRow = new HygieneTreeItem(
+ "Run Log",
+ "storageInfo",
+ [],
+ vscode.TreeItemCollapsibleState.None,
+ `${status.runLog.lineCount} events · ${kb(status.runLog.sizeBytes)} KB` +
+ (overCap > 0 ? ` · ${overCap} over cap` : "")
+ );
+ runLogRow.iconPath = new vscode.ThemeIcon("output");
+ runLogRow.tooltip = ".vscode/meridian/run-log.v1.jsonl — compacted per retention settings.";
+ rows.push(runLogRow);
+
+ const pulseRow = new HygieneTreeItem(
+ "Pulse History",
+ "storageInfo",
+ [],
+ vscode.TreeItemCollapsibleState.None,
+ `${status.pulse.snapshotCount}/${status.pulse.maxSnapshots} snapshots · ${kb(status.pulse.sizeBytes)} KB`
+ );
+ pulseRow.iconPath = new vscode.ThemeIcon("pulse");
+ pulseRow.tooltip = ".meridian/pulse/ — self-capping; no action needed.";
+ rows.push(pulseRow);
+
+ const section = new HygieneTreeItem(
+ "Meridian Storage",
+ "storageSection",
+ rows,
+ vscode.TreeItemCollapsibleState.Collapsed,
+ a.wouldPruneCount > 0 ? `${a.wouldPruneCount} prunable` : undefined
+ );
+ section.iconPath = new vscode.ThemeIcon("server");
+ section.tooltip = "Meridian-owned storage (self-policing, ADR 019)";
+ return section;
+ }
+
private buildImpactSection(): HygieneTreeItem {
const r = this.lastImpactResult!;
const m = r.metrics;
diff --git a/tests/ecosystems.test.ts b/tests/ecosystems.test.ts
new file mode 100644
index 0000000..29f4bcb
--- /dev/null
+++ b/tests/ecosystems.test.ts
@@ -0,0 +1,200 @@
+/**
+ * Ecosystem registry tests (ADR 018) — derivation invariants, legacy-superset
+ * guards (the derived lists must cover everything the hand-maintained lists
+ * covered before the registry existed), and JVM coverage fixtures.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+ ECOSYSTEM_PROFILES,
+ ECOSYSTEM_ENV_DIRS,
+ ECOSYSTEM_CACHE_DIRS,
+ ECOSYSTEM_BUILD_DIRS,
+ ECOSYSTEM_VENDOR_DIRS,
+ ECOSYSTEM_ARTIFACT_EXTS,
+ ECOSYSTEM_SOURCE_EXTS,
+ ECOSYSTEM_CONFIG_EXTS,
+ dirExcludeGlobs,
+} from "../src/ecosystems";
+import { HYGIENE_SETTINGS, HYGIENE_ANALYTICS_EXCLUDE_PATTERNS } from "../src/constants";
+import { categorize, bucketForDirName, ARTIFACT_DIRS } from "../src/domains/hygiene/analytics-utils";
+
+// ============================================================================
+// Legacy lists as they existed before the registry (pre-ADR-018 snapshots).
+// If a registry edit ever drops one of these, that is a silent exclusion
+// regression — the exact failure mode the registry exists to prevent.
+// ============================================================================
+
+const LEGACY_SCAN_EXCLUDE_DIRS = [
+ "dist", "build", "out", "bundled",
+ ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache",
+ ".ruff_cache", ".tox", ".eggs",
+ "coverage", ".nyc_output", ".cache",
+];
+
+const LEGACY_ANALYTICS_EXCLUDE_DIRS = [
+ ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache",
+ ".ruff_cache", ".tox", ".eggs",
+ ".yarn", ".pnpm-store", "vendor", ".bundle", ".gradle",
+ ".terraform", ".dart_tool", "deps", ".stack-work", ".cpcache",
+ // Documented exceptions (ADR 018): "_build" is a build-output dir —
+ // analytics now recurses it for prune candidates like dist/ and target/;
+ // "packages" was dropped from the registry entirely — yarn/pnpm monorepos
+ // keep first-party source there and excluding it blinded the hygiene scan.
+];
+
+const LEGACY_HEAVY_DIRS = [
+ "node_modules", "venv", ".venv",
+ "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs",
+ ".yarn", ".pnpm-store", "vendor", ".bundle", ".gradle",
+ ".terraform", ".dart_tool", "deps", ".stack-work", ".cpcache",
+ // "_build" and "packages" moved out deliberately — see above.
+];
+
+const LEGACY_ARTIFACT_EXTS = [".class", ".pyc", ".pyo", ".o", ".obj", ".a", ".so"];
+const LEGACY_SOURCE_EXTS = [
+ ".ts", ".js", ".py", ".go", ".rs", ".java", ".rb", ".cs", ".tsx", ".jsx", ".sh", ".bash",
+];
+const LEGACY_CONFIG_EXTS = [".yml", ".yaml", ".json", ".toml", ".ini", ".env"];
+
+describe("ecosystem registry derivation", () => {
+ it("dir buckets are pairwise disjoint (a name resolves to exactly one bucket)", () => {
+ const buckets = [
+ ECOSYSTEM_ENV_DIRS,
+ ECOSYSTEM_CACHE_DIRS,
+ ECOSYSTEM_BUILD_DIRS,
+ ECOSYSTEM_VENDOR_DIRS,
+ ];
+ for (let i = 0; i < buckets.length; i++) {
+ for (let j = i + 1; j < buckets.length; j++) {
+ const overlap = [...buckets[i]].filter((n) => buckets[j].has(n));
+ expect(overlap).toEqual([]);
+ }
+ }
+ });
+
+ it("source and config extension sets are disjoint", () => {
+ const overlap = [...ECOSYSTEM_SOURCE_EXTS].filter((e) => ECOSYSTEM_CONFIG_EXTS.has(e));
+ expect(overlap).toEqual([]);
+ });
+
+ it("every profile entry is non-empty and extensions carry a leading dot", () => {
+ for (const profile of ECOSYSTEM_PROFILES) {
+ for (const dir of [
+ ...(profile.envDirs ?? []), ...(profile.cacheDirs ?? []),
+ ...(profile.buildDirs ?? []), ...(profile.vendorDirs ?? []),
+ ]) {
+ expect(dir.length).toBeGreaterThan(0);
+ expect(dir).not.toContain("/");
+ }
+ for (const ext of [
+ ...(profile.artifactExts ?? []), ...(profile.sourceExts ?? []),
+ ...(profile.configExts ?? []),
+ ]) {
+ expect(ext.startsWith(".")).toBe(true);
+ }
+ }
+ });
+
+ it("dirExcludeGlobs defaults to descendant-only form, deterministically sorted", () => {
+ const globs = dirExcludeGlobs([new Set(["b", "a"])]);
+ expect(globs).toEqual(["**/a/**", "**/b/**"]);
+ });
+
+ it('dirExcludeGlobs "both" adds the dir-path form (analytics walker contract)', () => {
+ const globs = dirExcludeGlobs([new Set(["a"])], "both");
+ expect(globs).toEqual(["**/a/**", "**/a"]);
+ });
+
+ it("scan excludes carry no bare dir-path forms (a FILE named 'dist' is not excluded)", () => {
+ for (const pattern of HYGIENE_SETTINGS.EXCLUDE_PATTERNS) {
+ if (pattern.startsWith("**/") && !pattern.includes(".egg-info")) {
+ expect(pattern.endsWith("/**")).toBe(true);
+ }
+ }
+ });
+});
+
+describe("legacy-superset guards (no silent exclusion regressions)", () => {
+ it("hygiene scan excludes cover every legacy scan-exclude dir", () => {
+ for (const dir of LEGACY_SCAN_EXCLUDE_DIRS) {
+ expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).toContain(`**/${dir}/**`);
+ }
+ expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).toContain("**/*.egg-info/**");
+ });
+
+ it("analytics excludes cover every legacy analytics-exclude dir", () => {
+ for (const dir of LEGACY_ANALYTICS_EXCLUDE_DIRS) {
+ expect(HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).toContain(`**/${dir}/**`);
+ }
+ expect(HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).toContain("**/*.egg-info/**");
+ });
+
+ it("analytics excludes do NOT exclude build-output dirs (recursed for prune candidates)", () => {
+ for (const dir of ECOSYSTEM_BUILD_DIRS) {
+ expect(HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).not.toContain(`**/${dir}/**`);
+ }
+ });
+
+ it("heavy/placeholder membership (env∪cache∪vendor) covers every legacy heavy dir", () => {
+ const heavy = new Set([
+ ...ECOSYSTEM_ENV_DIRS, ...ECOSYSTEM_CACHE_DIRS, ...ECOSYSTEM_VENDOR_DIRS,
+ ]);
+ for (const dir of LEGACY_HEAVY_DIRS) {
+ expect(heavy.has(dir), `missing heavy dir: ${dir}`).toBe(true);
+ }
+ });
+
+ it("categorization ext sets cover every legacy ext", () => {
+ for (const ext of LEGACY_ARTIFACT_EXTS) expect(ECOSYSTEM_ARTIFACT_EXTS.has(ext)).toBe(true);
+ for (const ext of LEGACY_SOURCE_EXTS) expect(ECOSYSTEM_SOURCE_EXTS.has(ext)).toBe(true);
+ for (const ext of LEGACY_CONFIG_EXTS) expect(ECOSYSTEM_CONFIG_EXTS.has(ext)).toBe(true);
+ });
+});
+
+describe("JVM coverage (the enterprise-footprint gap ADR 018 closes)", () => {
+ it("Maven target/ is excluded from the hygiene scan", () => {
+ expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).toContain("**/target/**");
+ });
+
+ it("Gradle and Kotlin caches are excluded and bucketed as caches", () => {
+ for (const dir of [".gradle", ".kotlin"]) {
+ expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).toContain(`**/${dir}/**`);
+ expect(bucketForDirName(dir)).toBe("caches");
+ }
+ });
+
+ it("target/ buckets as a build output and its contents categorize as artifact", () => {
+ expect(bucketForDirName("target")).toBe("buildOutputs");
+ expect(ARTIFACT_DIRS.has("target")).toBe(true);
+ });
+
+ it("packaged JVM outputs categorize as artifact", () => {
+ for (const ext of [".class", ".jar", ".war", ".ear", ".hprof"]) {
+ expect(categorize(ext, `app${ext}`, `libs/app${ext}`)).toBe("artifact");
+ }
+ });
+
+ it("JVM-family sources categorize as source", () => {
+ for (const ext of [".java", ".kt", ".kts", ".scala", ".groovy"]) {
+ expect(categorize(ext, `Main${ext}`, `src/Main${ext}`)).toBe("source");
+ }
+ });
+
+ it("pom.xml and application.properties categorize as config, not other", () => {
+ expect(categorize(".xml", "pom.xml", "pom.xml")).toBe("config");
+ expect(categorize(".properties", "application.properties", "src/main/resources/application.properties")).toBe("config");
+ expect(categorize(".gradle", "build.gradle", "build.gradle")).toBe("config");
+ });
+
+ it("bare bin/ is deliberately NOT excluded (documented false-positive tradeoff)", () => {
+ expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).not.toContain("**/bin/**");
+ expect(bucketForDirName("bin")).toBeNull();
+ });
+
+ it("packages/ is deliberately NOT excluded (yarn/pnpm monorepo first-party source)", () => {
+ expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).not.toContain("**/packages/**");
+ expect(HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).not.toContain("**/packages/**");
+ expect(bucketForDirName("packages")).toBeNull();
+ });
+});
diff --git a/tests/fixtures.ts b/tests/fixtures.ts
index f2c0208..725a494 100644
--- a/tests/fixtures.ts
+++ b/tests/fixtures.ts
@@ -165,6 +165,13 @@ export class MockRunLog implements RunLog {
const safe = Math.max(0, Math.trunc(limit));
return success(safe === 0 ? [] : this.events.slice(-safe));
}
+
+ async compact(maxEvents: number): Promise> {
+ if (maxEvents <= 0 || this.events.length <= maxEvents) return success(0);
+ const dropped = this.events.length - maxEvents;
+ this.events = this.events.slice(-maxEvents);
+ return success(dropped);
+ }
}
// ============================================================================
diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts
index cf80262..f29ee36 100644
--- a/tests/manifest.test.ts
+++ b/tests/manifest.test.ts
@@ -27,6 +27,7 @@ const SPECIALIZED_COMMANDS = new Set([
"meridian.hygiene.deleteFile",
"meridian.hygiene.ignoreFile",
"meridian.hygiene.impactAnalysis", // ADR 005: active-file fallback + InputBox prompt
+ "meridian.hygiene.pruneStorage", // ADR 019: status preview + confirmation modal
]);
/** View lifecycle commands. No routing, no palette exposure. */
diff --git a/tests/pulseStore.test.ts b/tests/pulseStore.test.ts
new file mode 100644
index 0000000..8a96b31
--- /dev/null
+++ b/tests/pulseStore.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Pulse store tests (ADR 019) — versioned JSONL append/read, self-ignore,
+ * cap compaction, and tolerant reads.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { FilePulseStore, PulseSnapshotV1, PULSE_SCHEMA_VERSION } from "../src/infrastructure/pulse-store";
+import { PULSE } from "../src/constants";
+import { MockLogger } from "./fixtures";
+
+function snapshot(overrides: Partial = {}): PulseSnapshotV1 {
+ return {
+ schemaVersion: PULSE_SCHEMA_VERSION,
+ timestampMs: Date.now(),
+ branch: "main",
+ uncommittedCount: 2,
+ ...overrides,
+ };
+}
+
+describe("FilePulseStore", () => {
+ let root: string;
+ let store: FilePulseStore;
+ let logger: MockLogger;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-pulse-"));
+ logger = new MockLogger();
+ store = new FilePulseStore(root, logger as never);
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ const pulseFile = () => path.join(root, ".meridian", "pulse", `pulse.v${PULSE_SCHEMA_VERSION}.jsonl`);
+
+ it("append materializes the dir with a self-ignoring .gitignore and persists the snapshot", async () => {
+ const result = await store.append(snapshot({ deadFileCount: 3 }));
+ expect(result.kind).toBe("ok");
+
+ expect(fs.readFileSync(path.join(root, ".meridian", "pulse", ".gitignore"), "utf8")).toBe("*\n");
+ const read = await store.readLatest(10);
+ expect(read.kind).toBe("ok");
+ if (read.kind === "ok") {
+ expect(read.value).toHaveLength(1);
+ expect(read.value[0].deadFileCount).toBe(3);
+ }
+ });
+
+ it("readLatest returns [] when no history exists (ENOENT is not an error)", async () => {
+ const read = await store.readLatest(10);
+ expect(read.kind).toBe("ok");
+ if (read.kind === "ok") expect(read.value).toEqual([]);
+ });
+
+ it("readLatest returns the newest N in oldest→newest order", async () => {
+ for (let i = 0; i < 5; i++) {
+ await store.append(snapshot({ timestampMs: 1000 + i, uncommittedCount: i }));
+ }
+ const read = await store.readLatest(3);
+ expect(read.kind).toBe("ok");
+ if (read.kind === "ok") {
+ expect(read.value.map((s) => s.uncommittedCount)).toEqual([2, 3, 4]);
+ }
+ });
+
+ it("rejects appends with an unsupported schemaVersion", async () => {
+ const bad = { ...snapshot(), schemaVersion: 99 } as unknown as PulseSnapshotV1;
+ const result = await store.append(bad);
+ expect(result.kind).toBe("err");
+ if (result.kind === "err") expect(result.error.code).toBe("PULSE_VERSION_UNSUPPORTED");
+ });
+
+ it("skips malformed and unsupported-version lines instead of failing the read", async () => {
+ await store.append(snapshot({ uncommittedCount: 7 }));
+ fs.appendFileSync(pulseFile(), "{not json\n" + JSON.stringify({ schemaVersion: 99 }) + "\n");
+ await store.append(snapshot({ uncommittedCount: 8 }));
+
+ const read = await store.readLatest(10);
+ expect(read.kind).toBe("ok");
+ if (read.kind === "ok") {
+ expect(read.value.map((s) => s.uncommittedCount)).toEqual([7, 8]);
+ }
+ });
+
+ it("tail-compacts past PULSE.MAX_SNAPSHOTS on append", async () => {
+ const lines = Array.from({ length: PULSE.MAX_SNAPSHOTS + 10 }, (_, i) =>
+ JSON.stringify(snapshot({ timestampMs: i, uncommittedCount: i }))
+ );
+ fs.mkdirSync(path.dirname(pulseFile()), { recursive: true });
+ fs.writeFileSync(pulseFile(), lines.join("\n") + "\n");
+
+ await store.append(snapshot({ uncommittedCount: 12345 }));
+
+ const raw = fs.readFileSync(pulseFile(), "utf8").trim().split("\n");
+ expect(raw.length).toBe(PULSE.MAX_SNAPSHOTS);
+ expect(JSON.parse(raw[raw.length - 1]).uncommittedCount).toBe(12345);
+ });
+});
diff --git a/tests/retention.test.ts b/tests/retention.test.ts
new file mode 100644
index 0000000..7b3550e
--- /dev/null
+++ b/tests/retention.test.ts
@@ -0,0 +1,150 @@
+/**
+ * Retention engine tests (ADR 019) — pure prune planning, artifact pruning
+ * against a real temp dir, and storage status (including the shared-plan
+ * guarantee that the preview equals the prune).
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+// retention → settings → vscode; an empty workspace mock makes readSetting
+// fall through to SETTING_DEFAULTS (50 files / 30 days / 5000 events).
+vi.mock("vscode", () => ({
+ workspace: {
+ getConfiguration: vi.fn(),
+ workspaceFolders: undefined,
+ },
+}));
+import {
+ ArtifactFileInfo,
+ computeStorageStatus,
+ listArtifacts,
+ planArtifactPrune,
+ pruneArtifacts,
+} from "../src/infrastructure/retention";
+import { MockLogger } from "./fixtures";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const NOW = 1_800_000_000_000;
+
+function file(name: string, ageDays: number, sizeBytes = 100): ArtifactFileInfo {
+ return { name, mtimeMs: NOW - ageDays * DAY_MS, sizeBytes };
+}
+
+describe("planArtifactPrune (pure)", () => {
+ it("keeps everything when both rules are disabled (0)", () => {
+ const files = [file("a.json", 400), file("b.csv", 1)];
+ const { keep, prune } = planArtifactPrune(files, { artifactsMaxCount: 0, artifactsMaxAgeDays: 0 }, NOW);
+ expect(keep).toHaveLength(2);
+ expect(prune).toHaveLength(0);
+ });
+
+ it("prunes files older than maxAgeDays regardless of count", () => {
+ const files = [file("old.json", 31), file("fresh.json", 29)];
+ const { prune } = planArtifactPrune(files, { artifactsMaxCount: 0, artifactsMaxAgeDays: 30 }, NOW);
+ expect(prune.map((f) => f.name)).toEqual(["old.json"]);
+ });
+
+ it("count cap keeps the newest N; oldest overflow prunes first", () => {
+ const files = [file("d1.json", 1), file("d3.json", 3), file("d2.json", 2), file("d4.json", 4)];
+ const { keep, prune } = planArtifactPrune(files, { artifactsMaxCount: 2, artifactsMaxAgeDays: 0 }, NOW);
+ expect(keep.map((f) => f.name)).toEqual(["d1.json", "d2.json"]);
+ expect(prune.map((f) => f.name).sort()).toEqual(["d3.json", "d4.json"]);
+ });
+
+ it("age and count rules compose (age victims don't consume count slots)", () => {
+ const files = [file("ancient.json", 90), file("d1.json", 1), file("d2.json", 2)];
+ const { keep, prune } = planArtifactPrune(files, { artifactsMaxCount: 2, artifactsMaxAgeDays: 30 }, NOW);
+ expect(keep.map((f) => f.name)).toEqual(["d1.json", "d2.json"]);
+ expect(prune.map((f) => f.name)).toEqual(["ancient.json"]);
+ });
+});
+
+describe("pruneArtifacts / listArtifacts / computeStorageStatus (fs)", () => {
+ let root: string;
+ let artifactsDir: string;
+ let logger: MockLogger;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-retention-"));
+ artifactsDir = path.join(root, ".meridian", "artifacts");
+ fs.mkdirSync(artifactsDir, { recursive: true });
+ fs.writeFileSync(path.join(artifactsDir, ".gitignore"), "*\n");
+ logger = new MockLogger();
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ function writeArtifact(name: string, ageDays: number, content = "x".repeat(50)): void {
+ const p = path.join(artifactsDir, name);
+ fs.writeFileSync(p, content);
+ const mtime = new Date(Date.now() - ageDays * DAY_MS);
+ fs.utimesSync(p, mtime, mtime);
+ }
+
+ it("listArtifacts excludes the self-ignore .gitignore and returns [] for a missing dir", async () => {
+ writeArtifact("report.json", 1);
+ const files = await listArtifacts(root);
+ expect(files.map((f) => f.name)).toEqual(["report.json"]);
+
+ const empty = await listArtifacts(path.join(root, "nowhere"));
+ expect(empty).toEqual([]);
+ });
+
+ it("prunes by count, keeping the newest and never touching .gitignore", async () => {
+ writeArtifact("oldest.json", 5);
+ writeArtifact("middle.json", 3);
+ writeArtifact("newest.json", 1);
+
+ const result = await pruneArtifacts(root, { artifactsMaxCount: 1, artifactsMaxAgeDays: 0 }, logger as never);
+ expect(result.kind).toBe("ok");
+ if (result.kind === "ok") {
+ expect(result.value.deletedCount).toBe(2);
+ expect(result.value.freedBytes).toBe(100);
+ }
+ expect(fs.readdirSync(artifactsDir).sort()).toEqual([".gitignore", "newest.json"]);
+ });
+
+ it("prunes by age", async () => {
+ writeArtifact("stale.json", 40);
+ writeArtifact("fresh.json", 1);
+
+ const result = await pruneArtifacts(root, { artifactsMaxCount: 0, artifactsMaxAgeDays: 30 }, logger as never);
+ expect(result.kind).toBe("ok");
+ if (result.kind === "ok") expect(result.value.deletedCount).toBe(1);
+ expect(fs.existsSync(path.join(artifactsDir, "stale.json"))).toBe(false);
+ expect(fs.existsSync(path.join(artifactsDir, "fresh.json"))).toBe(true);
+ });
+
+ it("storage status reports footprint and a would-prune preview matching the actual prune", async () => {
+ writeArtifact("a.json", 40);
+ writeArtifact("b.json", 1);
+ fs.mkdirSync(path.join(root, ".vscode", "meridian"), { recursive: true });
+ fs.writeFileSync(path.join(root, ".vscode", "meridian", "run-log.v1.jsonl"), '{"e":1}\n{"e":2}\n');
+
+ const status = await computeStorageStatus(root);
+ expect(status.kind).toBe("ok");
+ if (status.kind !== "ok") return;
+
+ expect(status.value.artifacts.fileCount).toBe(2);
+ expect(status.value.artifacts.totalBytes).toBe(100);
+ expect(status.value.runLog.lineCount).toBe(2);
+ // Default policy (30d/50 files): a.json (40d) is the only would-prune victim.
+ expect(status.value.artifacts.wouldPruneCount).toBe(1);
+ expect(status.value.artifacts.wouldPruneBytes).toBe(50);
+
+ const pruned = await pruneArtifacts(
+ root,
+ { artifactsMaxCount: status.value.policy.artifactsMaxCount, artifactsMaxAgeDays: status.value.policy.artifactsMaxAgeDays },
+ logger as never
+ );
+ if (pruned.kind === "ok") {
+ expect(pruned.value.deletedCount).toBe(status.value.artifacts.wouldPruneCount);
+ expect(pruned.value.freedBytes).toBe(status.value.artifacts.wouldPruneBytes);
+ }
+ });
+});
diff --git a/tests/run-log.test.ts b/tests/run-log.test.ts
index 04c86ba..1057ce3 100644
--- a/tests/run-log.test.ts
+++ b/tests/run-log.test.ts
@@ -121,5 +121,49 @@ describe("run-log", () => {
expect(isSupportedRunEventVersion(1)).toBe(true);
});
});
+
+ it("compact keeps only the newest N events, atomically, and reports dropped count", async () => {
+ await withTempWorkspace(async (workspaceRoot) => {
+ const log = new FileRunLog(workspaceRoot, new MockLogger());
+ for (let i = 0; i < 10; i++) {
+ await log.append(makeEvent({ runId: `run-${i}` }));
+ }
+
+ const compacted = await log.compact(4);
+ expect(compacted.kind).toBe("ok");
+ if (compacted.kind === "ok") expect(compacted.value).toBe(6);
+
+ const latest = await log.readLatest(100);
+ expect(latest.kind).toBe("ok");
+ if (latest.kind === "ok") {
+ expect(latest.value.map((e) => e.runId)).toEqual(["run-6", "run-7", "run-8", "run-9"]);
+ }
+ // No tmp residue from the atomic rewrite.
+ const dir = await fs.readdir(path.join(workspaceRoot, ".vscode", "meridian"));
+ expect(dir).toEqual(["run-log.v1.jsonl"]);
+ });
+ });
+
+ it("compact is a no-op when disabled (0) or under the cap, and on a missing file", async () => {
+ await withTempWorkspace(async (workspaceRoot) => {
+ const log = new FileRunLog(workspaceRoot, new MockLogger());
+
+ const missing = await log.compact(5);
+ expect(missing.kind).toBe("ok");
+ if (missing.kind === "ok") expect(missing.value).toBe(0);
+
+ await log.append(makeEvent());
+ const disabled = await log.compact(0);
+ expect(disabled.kind).toBe("ok");
+ if (disabled.kind === "ok") expect(disabled.value).toBe(0);
+
+ const underCap = await log.compact(10);
+ expect(underCap.kind).toBe("ok");
+ if (underCap.kind === "ok") expect(underCap.value).toBe(0);
+
+ const latest = await log.readLatest(10);
+ if (latest.kind === "ok") expect(latest.value).toHaveLength(1);
+ });
+ });
});
diff --git a/tests/sessionAggregator.test.ts b/tests/sessionAggregator.test.ts
index 17bbee6..41b570a 100644
--- a/tests/sessionAggregator.test.ts
+++ b/tests/sessionAggregator.test.ts
@@ -654,6 +654,110 @@ describe('aggregateSessionBriefing', () => {
expect(result.value.pendingChangeCompanions).toBeUndefined();
});
+ // ── Pulse history (ADR 019) ────────────────────────────────────────────────
+
+ function makePulseStore(history: import('../src/infrastructure/pulse-store').PulseSnapshotV1[] = []) {
+ return {
+ readLatest: vi.fn().mockResolvedValue(success(history)),
+ append: vi.fn().mockResolvedValue(success(void 0)),
+ };
+ }
+
+ function pastSnapshot(overrides: Partial = {}) {
+ return {
+ schemaVersion: 1 as const,
+ timestampMs: Date.now() - 60 * 60 * 1000, // 1h ago — clear of the throttle
+ branch: 'main',
+ uncommittedCount: 5,
+ commitsInWindow: 40,
+ deadFileCount: 4,
+ ...overrides,
+ };
+ }
+
+ it('omits the pulse slice when no pulse store is injected', async () => {
+ const result = await aggregateSessionBriefing(makeSources(git, logger, runLog));
+ if (result.kind !== 'ok') throw new Error('expected ok');
+ expect(result.value.pulse).toBeUndefined();
+ });
+
+ it('first briefing: pulse has no deltas, appends, and series holds the current point', async () => {
+ const store = makePulseStore([]);
+ const result = await aggregateSessionBriefing(
+ makeSources(git, logger, runLog, { pulseStore: store as never })
+ );
+ if (result.kind !== 'ok') throw new Error('expected ok');
+ const pulse = result.value.pulse;
+ expect(pulse).toBeDefined();
+ expect(pulse?.deltas).toBeUndefined();
+ expect(pulse?.previousAt).toBeUndefined();
+ expect(pulse?.appended).toBe(true);
+ expect(pulse?.series).toHaveLength(1);
+ expect(store.append).toHaveBeenCalledOnce();
+ const stored = store.append.mock.calls[0][0];
+ expect(stored.schemaVersion).toBe(1);
+ expect(stored.branch).toBe('main');
+ expect(stored.commitsInWindow).toBe(42); // from makeAnalyzer summary
+ });
+
+ it('computes deltas against the previous snapshot and appends past the throttle window', async () => {
+ git.setAllChanges([createMockChange('a.ts', 'M'), createMockChange('b.ts', 'M')]);
+ const store = makePulseStore([pastSnapshot()]);
+ const result = await aggregateSessionBriefing(
+ makeSources(git, logger, runLog, { pulseStore: store as never })
+ );
+ if (result.kind !== 'ok') throw new Error('expected ok');
+ const pulse = result.value.pulse;
+ expect(pulse?.previousAt).toBeDefined();
+ expect(pulse?.deltas?.uncommittedCount).toBe(2 - 5);
+ expect(pulse?.deltas?.commitsInWindow).toBe(42 - 40);
+ // No hygiene scan cached → current side unmeasured → no dead-file delta.
+ expect(pulse?.deltas?.deadFileCount).toBeUndefined();
+ expect(pulse?.appended).toBe(true);
+ expect(pulse?.series).toHaveLength(2);
+ });
+
+ it('throttles the append when the previous snapshot is inside MIN_APPEND_INTERVAL_MS', async () => {
+ const store = makePulseStore([pastSnapshot({ timestampMs: Date.now() - 1000 })]);
+ const result = await aggregateSessionBriefing(
+ makeSources(git, logger, runLog, { pulseStore: store as never })
+ );
+ if (result.kind !== 'ok') throw new Error('expected ok');
+ expect(store.append).not.toHaveBeenCalled();
+ expect(result.value.pulse?.appended).toBe(false);
+ // Slice still renders: deltas + series ending at "now".
+ expect(result.value.pulse?.deltas).toBeDefined();
+ expect(result.value.pulse?.series).toHaveLength(2);
+ });
+
+ it('flags and omits pulse when the history read fails (fail-soft)', async () => {
+ const store = {
+ readLatest: vi.fn().mockResolvedValue(failure({ code: 'PULSE_READ_ERROR', message: 'x' })),
+ append: vi.fn(),
+ };
+ const result = await aggregateSessionBriefing(
+ makeSources(git, logger, runLog, { pulseStore: store as never })
+ );
+ if (result.kind !== 'ok') throw new Error('expected ok');
+ expect(result.value.pulse).toBeUndefined();
+ expect(result.value.flags).toContain('Pulse history unavailable');
+ expect(store.append).not.toHaveBeenCalled();
+ });
+
+ it('flags but still returns the slice when the append fails (fail-soft)', async () => {
+ const store = {
+ readLatest: vi.fn().mockResolvedValue(success([pastSnapshot()])),
+ append: vi.fn().mockResolvedValue(failure({ code: 'PULSE_WRITE_ERROR', message: 'x' })),
+ };
+ const result = await aggregateSessionBriefing(
+ makeSources(git, logger, runLog, { pulseStore: store as never })
+ );
+ if (result.kind !== 'ok') throw new Error('expected ok');
+ expect(result.value.flags).toContain('Pulse history not recorded');
+ expect(result.value.pulse).toBeDefined();
+ expect(result.value.pulse?.appended).toBe(false);
+ });
+
// The session-briefing webview's FLAG_ANCHORS table couples to literal flag
// prefixes emitted from this aggregator. The webview JS isn't importable here
// (ES5 IIFE served as a static asset), so this test pins the contract from
diff --git a/tests/storageHandler.test.ts b/tests/storageHandler.test.ts
new file mode 100644
index 0000000..1ed6993
--- /dev/null
+++ b/tests/storageHandler.test.ts
@@ -0,0 +1,130 @@
+/**
+ * Hygiene storage handler tests (ADR 019) — hygiene.storageStatus and
+ * hygiene.pruneStorage over a real temp workspace.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { CommandContext } from "../src/types";
+import { MockLogger, MockRunLog } from "./fixtures";
+import type { StorageStatus } from "../src/infrastructure/retention";
+import type { PruneStorageOutcome } from "../src/domains/hygiene/storage-handler";
+
+// storage-handler → retention → settings → vscode; empty mock → typed defaults.
+vi.mock("vscode", () => ({
+ workspace: {
+ getConfiguration: vi.fn(),
+ workspaceFolders: undefined,
+ },
+}));
+
+import { createStorageHandlers } from "../src/domains/hygiene/storage-handler";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+describe("hygiene storage handlers", () => {
+ let root: string;
+ let ctx: CommandContext;
+ let logger: MockLogger;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-storage-"));
+ ctx = { extensionPath: "", workspaceFolders: [root] };
+ logger = new MockLogger();
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ function writeArtifact(name: string, ageDays: number): void {
+ const dir = path.join(root, ".meridian", "artifacts");
+ fs.mkdirSync(dir, { recursive: true });
+ const p = path.join(dir, name);
+ fs.writeFileSync(p, "x".repeat(10));
+ const mtime = new Date(Date.now() - ageDays * DAY_MS);
+ fs.utimesSync(p, mtime, mtime);
+ }
+
+ it("storageStatus reports an empty workspace cleanly", async () => {
+ const { storageStatus } = createStorageHandlers(logger as never);
+ const result = await storageStatus(ctx, {});
+ expect(result.kind).toBe("ok");
+ if (result.kind !== "ok") return;
+ const status = result.value as StorageStatus;
+ expect(status.artifacts.fileCount).toBe(0);
+ expect(status.artifacts.wouldPruneCount).toBe(0);
+ expect(status.runLog.lineCount).toBe(0);
+ expect(status.pulse.snapshotCount).toBe(0);
+ });
+
+ it("storageStatus fails when no workspace root is resolvable", async () => {
+ const { storageStatus } = createStorageHandlers(logger as never);
+ const result = await storageStatus({ extensionPath: "", workspaceFolders: [] }, {});
+ expect(result.kind).toBe("err");
+ if (result.kind === "err") expect(result.error.code).toBe("WORKSPACE_NOT_FOUND");
+ });
+
+ it("pruneStorage prunes stale artifacts and compacts the run log", async () => {
+ writeArtifact("stale.json", 40); // default maxAgeDays 30 → prune
+ writeArtifact("fresh.json", 1);
+
+ const runLog = new MockRunLog();
+ // Over the default 5000-event cap.
+ runLog.setEvents(
+ Array.from({ length: 5010 }, (_, i) => ({
+ schemaVersion: 1 as const,
+ eventId: `e${i}`,
+ runId: `r${i}`,
+ timestampMs: i,
+ source: "router" as const,
+ phase: "start" as const,
+ commandName: "git.status" as const,
+ }))
+ );
+
+ const { pruneStorage } = createStorageHandlers(logger as never, runLog);
+ const result = await pruneStorage(ctx, {});
+ expect(result.kind).toBe("ok");
+ if (result.kind !== "ok") return;
+ const outcome = result.value as PruneStorageOutcome;
+ expect(outcome.artifacts.deletedCount).toBe(1);
+ expect(outcome.runLogDropped).toBe(10);
+ expect(fs.existsSync(path.join(root, ".meridian", "artifacts", "fresh.json"))).toBe(true);
+ expect(fs.existsSync(path.join(root, ".meridian", "artifacts", "stale.json"))).toBe(false);
+ });
+
+ it("pruneStorage succeeds without a run log (artifacts-only)", async () => {
+ writeArtifact("stale.json", 40);
+ const { pruneStorage } = createStorageHandlers(logger as never);
+ const result = await pruneStorage(ctx, {});
+ expect(result.kind).toBe("ok");
+ if (result.kind !== "ok") return;
+ expect((result.value as PruneStorageOutcome).runLogDropped).toBe(0);
+ });
+
+ it("storageStatus is TTL-cached per root and invalidated by a successful prune", async () => {
+ const { storageStatus, pruneStorage } = createStorageHandlers(logger as never);
+
+ const first = await storageStatus(ctx, {});
+ expect(first.kind).toBe("ok");
+ if (first.kind !== "ok") return;
+ expect((first.value as StorageStatus).artifacts.fileCount).toBe(0);
+
+ // A new artifact inside the TTL window is invisible — cached value served.
+ writeArtifact("stale.json", 40);
+ const second = await storageStatus(ctx, {});
+ if (second.kind !== "ok") return;
+ expect((second.value as StorageStatus).artifacts.fileCount).toBe(0);
+
+ // Prune invalidates: the post-prune status recomputes from disk.
+ const pruned = await pruneStorage(ctx, {});
+ expect(pruned.kind).toBe("ok");
+ const third = await storageStatus(ctx, {});
+ if (third.kind !== "ok") return;
+ expect((third.value as StorageStatus).artifacts.fileCount).toBe(0); // stale.json pruned
+ expect((third.value as StorageStatus).artifacts.wouldPruneCount).toBe(0);
+ });
+});