Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Create a commit with a provided message. Validates the message, with comprehensi
### **git.showAnalytics**
Open a full-screen dashboard displaying Git analytics: churn (commits per file), volatility (recent change frequency), authorship (commits per author), commit trends (over time), and top contributors. Includes a **Risk Hotspots** scatter — each file plotted by change frequency (x) against volatility (y), bubble size by total lines changed, colored by risk tier — so refactor candidates surface in the top-right at a glance. A **Change Companions** table surfaces files that change together in the same commit (co-change count + co-change rate), revealing hidden coupling — candidates to refactor together or split apart. Real-time chart rendering with drill-down.

**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. Every report render (initial open, refresh, or filter) also refreshes that report's `.meridian/latest/` agent-readable snapshot ([ADR 020](./adr/020-latest-snapshot-contract.md)).
**Export (all report dashboards).** Each report webview (Git Analytics, Hygiene Analytics, Session Briefing) offers two save paths: **↓ CSV / ↓ JSON / ↓ MD** 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. **Copy MD** copies the markdown rendering to the clipboard through the `security.clipboard.autoCopy` policy. Every report render (initial open, refresh, or filter) also refreshes that report's `.meridian/latest/` agent-readable snapshot ([ADR 020](./adr/020-latest-snapshot-contract.md)).

### **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), 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. Raised flags are surfaced as actionable cards in the briefing panel, each with a jump-to-section link and one-click actions. Useful for standup notes, context switching, pre-commit risk triage, or morning orientation.
Expand Down Expand Up @@ -64,7 +64,7 @@ Meridian polices its own storage ([ADR 019](./adr/019-pulse-and-retention.md)).
## Sidebar Views & UI

### **Panel Reports View** (`meridian.reports.view`)
First view in the Meridian activity-bar container — the anchored, first-class entry point for the three webview reports, in order: **Session Briefing**, **Git Analytics**, **Hygiene Analytics**. Report rows carry no glyphs by design. Single-click (or the hover **View** action, `$(open-preview)`) reveals a live panel without recomputing, or runs the report once if no panel is open. The hover **Refresh** action (`$(refresh)`) always recomputes. The former in-tree "View Git Report" / "View Hygiene Report" nodes and the Git/Hygiene view-title report icons were removed — report entry now lives exclusively here.
First view in the Meridian activity-bar container — the anchored, first-class entry point for the three webview reports, in order: **Session Briefing**, **Git Analytics**, **Hygiene Analytics**. Report rows carry no glyphs by design. Single-click (or the hover **View** action, `$(open-preview)`) reveals a live panel without recomputing, or runs the report once if no panel is open. The hover **Refresh** action (`$(refresh)`) always recomputes. Each row shows a coarse freshness description ("just now", "5m ago", …) read from its `.meridian/latest/` snapshot mtime, redrawn on every snapshot write; the view-title **Reveal Latest Snapshots** action (`Meridian: Reveal Latest Snapshots`, also in the palette) reveals `latest/` in the Explorer ([ADR 020](./adr/020-latest-snapshot-contract.md)). The former in-tree "View Git Report" / "View Hygiene Report" nodes and the Git/Hygiene view-title report icons were removed — report entry now lives exclusively here.

### **Git View** (`meridian.git.view`)
Browse current branch, dirty state, change groups (staged/unstaged/untracked, expandable to files), and recent commits. The Git Analytics report is reached via the Panel Reports view.
Expand Down
4 changes: 4 additions & 0 deletions docs/adr/020-latest-snapshot-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ new subsystem.
- **Layering invariant.** `src/infrastructure/latest-snapshot.ts` has no
`vscode` import and no dependency on `src/domains/*`; `BaseWebviewProvider`
is the only caller. Consistent with `retention.ts` / `jsonl-tail.ts`.
- **Internal consumer.** The Reports tree is a sanctioned internal consumer
of `latest/` — but mtime-only: it stats the three files for its coarse
"updated Nm ago" row descriptions (redrawn via `onLatestSnapshotWrite`)
and never parses the JSON, so it cannot couple to the envelope shape.

## Cross-references

Expand Down
17 changes: 16 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@
"dark": "media/glyph-dark.svg"
},
"category": "Meridian"
},
{
"command": "meridian.latest.reveal",
"title": "Reveal Latest Snapshots",
"icon": "$(folder-opened)",
"category": "Meridian"
}
],
"configuration": {
Expand Down Expand Up @@ -403,6 +409,10 @@
{
"command": "meridian.hygiene.impactAnalysis",
"when": "workspaceFolderCount > 0"
},
{
"command": "meridian.latest.reveal",
"when": "workspaceFolderCount > 0"
}
],
"view/title": [
Expand All @@ -417,10 +427,15 @@
"group": "navigation@1"
},
{
"command": "meridian.openSettings",
"command": "meridian.latest.reveal",
"when": "view == meridian.reports.view",
"group": "navigation@1"
},
{
"command": "meridian.openSettings",
"when": "view == meridian.reports.view",
"group": "navigation@2"
},
{
"command": "meridian.openSettings",
"when": "view == meridian.git.view",
Expand Down
69 changes: 68 additions & 1 deletion src/domains/git/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
TrendData,
} from "./analytics-types";
import { ANALYTICS_SETTINGS, CACHE_SETTINGS, CO_CHANGE, WORKSPACE_EXCLUDE_BASE } from "../../constants";
import { REPORT_LABELS, reportCsvHeader } from "../../report-labels";
import { REPORT_LABELS, reportCsvHeader, reportMdHeader, mdEscape } from "../../report-labels";
import { normalizeRenamePath } from "./git-path";
import { TtlCache } from "../../infrastructure/cache";
import { pathMatchesAny } from "../../infrastructure/glob-match";
Expand Down Expand Up @@ -636,3 +636,70 @@ export function gitReportToCsv(report: GitAnalyticsReport): string {

return lines.join("\n");
}

/**
* Convert a git analytics report to Markdown. Mirrors gitReportToCsv's
* section structure (same caps) so the two exports can never diverge in
* coverage, only in framing.
*/
export function gitReportToMd(report: GitAnalyticsReport): string {
const lines: string[] = [];

lines.push(reportMdHeader(REPORT_LABELS.gitAnalytics));
lines.push("");
lines.push(`- **Period:** ${report.period}`);
lines.push(`- **Generated:** ${report.generatedAt.toISOString()}`);
lines.push("");

const sum = report.summary;
lines.push("## Summary");
lines.push("");
lines.push("| Metric | Value |");
lines.push("| --- | --- |");
lines.push(`| Total Commits | ${sum.totalCommits} |`);
lines.push(`| Total Authors | ${sum.totalAuthors} |`);
lines.push(`| Total Files Modified | ${sum.totalFilesModified} |`);
lines.push(`| Lines Added | ${sum.totalLinesAdded} |`);
lines.push(`| Lines Deleted | ${sum.totalLinesDeleted} |`);
lines.push(`| Commit Frequency (per week) | ${sum.commitFrequency.toFixed(2)} |`);
lines.push(`| Avg Commit Size | ${sum.averageCommitSize.toFixed(2)} |`);
lines.push(`| Churn Rate | ${sum.churnRate.toFixed(2)} |`);
lines.push("");

lines.push("## Files");
lines.push("");
lines.push("| Path | Commits | Insertions | Deletions | Volatility | Risk | Authors | Last Modified |");
lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |");
for (const file of report.files.slice(0, ANALYTICS_SETTINGS.CSV_MAX_FILES)) {
lines.push(
`| ${mdEscape(file.path)} | ${file.commitCount} | ${file.insertions} | ${file.deletions} | ${file.volatility.toFixed(2)} | ${file.risk} | ${mdEscape(file.authors.join("; "))} | ${file.lastModified.toISOString()} |`
);
}
lines.push("");

lines.push("## Authors");
lines.push("");
lines.push("| Name | Commits | Insertions | Deletions | Files Changed | Last Active |");
lines.push("| --- | --- | --- | --- | --- | --- |");
for (const author of report.authors) {
lines.push(
`| ${mdEscape(author.name)} | ${author.commits} | ${author.insertions} | ${author.deletions} | ${author.filesChanged} | ${author.lastActive.toISOString()} |`
);
}

const coChange = report.coChange ?? [];
if (coChange.length > 0) {
lines.push("");
lines.push("## Change Companions");
lines.push("");
lines.push("| File A | File B | Co-Changes | Co-change % |");
lines.push("| --- | --- | --- | --- |");
for (const pair of coChange.slice(0, CO_CHANGE.CSV_MAX_PAIRS)) {
lines.push(
`| ${mdEscape(pair.a)} | ${mdEscape(pair.b)} | ${pair.count} | ${(pair.coChangeRate * 100).toFixed(0)} |`
);
}
}

return lines.join("\n");
}
2 changes: 2 additions & 0 deletions src/domains/git/analytics-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ <h1>Git Analytics</h1>
<div class="header-actions">
<button id="exportCsv" class="btn-secondary" title="Quick-save CSV to .meridian/artifacts/">↓ CSV</button>
<button id="exportJson" class="btn-secondary" title="Quick-save JSON to .meridian/artifacts/">↓ JSON</button>
<button id="exportMd" class="btn-secondary" title="Quick-save Markdown to .meridian/artifacts/">↓ MD</button>
<button id="copyMd" class="btn-secondary" title="Copy Markdown to clipboard">Copy MD</button>
<button id="exportAs" class="btn-secondary" title="Choose format and location…">Save as…</button>
<button id="refreshBtn">↺ Refresh</button>
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/domains/git/analytics-ui/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,14 @@ document.getElementById("exportCsv")?.addEventListener("click", () => {
vscode?.postMessage({ type: "export", format: "csv" });
});

document.getElementById("exportMd")?.addEventListener("click", () => {
vscode?.postMessage({ type: "export", format: "md" });
});

document.getElementById("copyMd")?.addEventListener("click", () => {
vscode?.postMessage({ type: "copyMarkdown" });
});

document.getElementById("exportAs")?.addEventListener("click", () => {
vscode?.postMessage({ type: "exportAs" });
});
Expand Down
3 changes: 3 additions & 0 deletions src/domains/git/session-briefing-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ <h1>Session Briefing</h1>
<div class="header-actions">
<button id="exportCsv" class="btn-secondary" title="Quick-save CSV to .meridian/artifacts/">&#8595; CSV</button>
<button id="exportJson" class="btn-secondary" title="Quick-save JSON to .meridian/artifacts/">&#8595; JSON</button>
<button id="exportMd" class="btn-secondary" title="Quick-save Markdown to .meridian/artifacts/">&#8595; MD</button>
<button id="copyMd" class="btn-secondary" title="Copy Markdown to clipboard">Copy MD</button>
<button id="exportAs" class="btn-secondary" title="Choose format and location&#8230;">Save as&#8230;</button>
<button id="refreshBtn">&#8634; Refresh</button>
</div>
Expand All @@ -40,6 +42,7 @@ <h3>Activity <span class="table-hint" id="activityPeriod"></span>
<span class="report-link adornment" data-report="gitAnalytics" title="Open in Git Analytics">&#8599;</span>
</h3>
<div class="metrics" id="activityMetrics"></div>
<div id="contributorsBlock"></div>
<div id="sparklineBlock"></div>
<div id="churnFilterRow" class="filter-row" style="display:none">
<input type="text" class="path-filter" data-target="churn" placeholder="filter path…" />
Expand Down
48 changes: 43 additions & 5 deletions src/domains/git/session-briefing-ui/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@
document.getElementById("exportJson").addEventListener("click", function () {
vscode.postMessage({ type: "export", format: "json" });
});
document.getElementById("exportMd").addEventListener("click", function () {
vscode.postMessage({ type: "export", format: "md" });
});
document.getElementById("copyMd").addEventListener("click", function () {
vscode.postMessage({ type: "copyMarkdown" });
});
document.getElementById("exportAs").addEventListener("click", function () {
vscode.postMessage({ type: "exportAs" });
});
Expand Down Expand Up @@ -120,8 +126,15 @@
// anchor and/or an action button. Keyed by id rather than matched against
// message text, so rewording a message never breaks the wiring. Flags
// without an entry here (or entries with neither `anchor` nor `action`)
// render as inert cards — acceptable, matching the prior regex-miss
// behavior.
// render as inert cards.
//
// Deliberately inert (no honest action exists — the condition is either
// environmental or self-describing):
// runlog.unavailable / runlog.readFailed — run log is a peripheral; there
// is no user command that creates or repairs it.
// pulse.unavailable — pulse store missing/unreadable; same, no command.
// pulse.notRecorded — throttle suppressed the write by design (ADR 019);
// "record now" would defeat the min-interval.
var HYGIENE_REPORT_UI = {
anchor: "hygieneSection",
action: { label: "Open Hygiene Analytics", message: { type: "openReport", payload: { id: "hygiene" } } },
Expand All @@ -132,11 +145,19 @@
anchor: "uncommittedSection",
action: { label: "Open Source Control", message: { type: "openScm" } },
},
"head.detached": {
action: { label: "Open Source Control", message: { type: "openScm" } },
},
"runs.failures": { anchor: "recentRunsSection" },
"risk.hotspots": {
anchor: "pendingRiskSection",
action: { label: "Open Git Analytics", message: { type: "openReport", payload: { id: "gitAnalytics" } } },
},
// Self-healing: opening Git Analytics computes the report, so the next
// briefing has the activity slice.
"analytics.unavailable": {
action: { label: "Open Git Analytics", message: { type: "openReport", payload: { id: "gitAnalytics" } } },
},
"companions.missing": { anchor: "companionsSection" },
"hygiene.deadFiles": HYGIENE_REPORT_UI,
"hygiene.largeFiles": HYGIENE_REPORT_UI,
Expand Down Expand Up @@ -242,9 +263,13 @@
section.style.display = "";

var hint = document.getElementById("pulseHint");
hint.textContent = p.previousAt
? "(since " + new Date(p.previousAt).toLocaleString() + ")"
: "(first snapshot — history starts now)";
var since = p.previousAt
? "since " + new Date(p.previousAt).toLocaleString()
: "first snapshot — history starts now";
var recorded = p.appended
? "recorded"
: "not recorded (within 10-min window)";
hint.textContent = "(" + since + " · " + recorded + ")";

var cards = [];
if (p.deltas) {
Expand Down Expand Up @@ -306,6 +331,19 @@
}
document.getElementById("activityMetrics").innerHTML = cards.join("");

// Top contributors — already aggregator-capped (SESSION_BRIEFING limit)
// and already in the CSV export; previously dropped by this renderer.
var contributors = w.topContributors || [];
document.getElementById("contributorsBlock").innerHTML = contributors.length
? '<h3 class="sub">Top Contributors</h3>' +
'<div class="contributor-chips">' +
contributors.map(function (a) {
return '<span class="contributor-chip">' + esc(a.name) +
'<span class="contributor-count">' + (Number(a.commits) || 0) + '</span></span>';
}).join("") +
'</div>'
: "";

var sb = document.getElementById("sparklineBlock");
var spark = sparklineSvg(w.commitFrequency && w.commitFrequency.data);
sb.innerHTML = spark
Expand Down
25 changes: 25 additions & 0 deletions src/domains/git/session-briefing-ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,31 @@ h3.sub {
margin: 0 0 14px;
}

/* Top-contributor chips (Activity section) */

.contributor-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}

.contributor-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid var(--vscode-input-border, #555);
border-radius: 12px;
font-size: 12px;
}

.contributor-count {
font-weight: 600;
font-size: 11px;
opacity: 0.6;
}

/* Pulse trend charts sit side-by-side where width allows, stack otherwise. */
#pulseTrendBlock {
display: flex;
Expand Down
2 changes: 2 additions & 0 deletions src/domains/hygiene/analytics-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ <h1>Hygiene Analytics</h1>
<div class="header-actions">
<button id="exportCsvBtn" class="btn-secondary" title="Quick-save CSV to .meridian/artifacts/">↓ CSV</button>
<button id="exportJsonBtn" class="btn-secondary" title="Quick-save JSON to .meridian/artifacts/">↓ JSON</button>
<button id="exportMdBtn" class="btn-secondary" title="Quick-save Markdown to .meridian/artifacts/">↓ MD</button>
<button id="copyMdBtn" class="btn-secondary" title="Copy Markdown to clipboard">Copy MD</button>
<button id="exportAsBtn" class="btn-secondary" title="Choose format and location…">Save as…</button>
<button id="refreshBtn">Refresh</button>
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/domains/hygiene/analytics-ui/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,14 @@ document.getElementById("exportCsvBtn")?.addEventListener("click", () => {
vscode?.postMessage({ type: "export", format: "csv" });
});

document.getElementById("exportMdBtn")?.addEventListener("click", () => {
vscode?.postMessage({ type: "export", format: "md" });
});

document.getElementById("copyMdBtn")?.addEventListener("click", () => {
vscode?.postMessage({ type: "copyMarkdown" });
});

document.getElementById("exportAsBtn")?.addEventListener("click", () => {
vscode?.postMessage({ type: "exportAs" });
});
Expand Down
Loading
Loading