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
8 changes: 4 additions & 4 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Trace the blast radius of a file or function change by analyzing imports, call s
Open a dashboard displaying Hygiene analytics: prune candidates over time, file-type breakdown, disk impact (total and per-category), and age/size distributions. Configurable thresholds via workspace settings.

### File actions (sidebar / explorer context)
- **Delete File** — remove a file flagged by the last scan (confirmation required).
- **Delete File** — remove a file flagged by the last scan (confirmation required; the file is moved to the OS trash where supported, so a mis-click stays recoverable).
- **Ignore File** — append the file's pattern to `.meridian/.meridianignore`.

### **hygiene.pruneStorage** (Meridian Storage)
Expand All @@ -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. 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.
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, and `Meridian: Refresh Latest Snapshots` recomputes all three reports and rewrites the snapshots without opening any panel — useful before pointing a coding agent at them ([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 Expand Up @@ -95,9 +95,9 @@ All features respect workspace settings under the `meridian.*` namespace, includ
Per-workspace Meridian state lives under `.meridian/` at the workspace root (see [ADR 014](./adr/014-dotdir-doctrine.md)):

- `.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/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 }`. Misconfigurations (unknown keys, wrong value types, malformed JSON) are still ignored at read time but reported once per file change in the `Meridian` output channel, so a typo never fails silently.
- `.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)).
- `.meridian/latest/` — local, self-gitignored, agent-readable snapshot of the three reports (`session-briefing.v1.json`, `git-analytics.v1.json`, `hygiene-analytics.v1.json`), each a `{ schemaVersion, kind, generatedAt, report }` envelope overwritten on every report render ("latest = last rendered"); no history, no runtime integration — coding agents read the files directly ([ADR 020](./adr/020-latest-snapshot-contract.md)).
- `.meridian/latest/` — local, self-gitignored, agent-readable snapshot of the three reports (`session-briefing.v1.json`, `git-analytics.v1.json`, `hygiene-analytics.v1.json`), each a `{ schemaVersion, kind, generatedAt, repo?, report }` envelope overwritten on every report render and by `Meridian: Refresh Latest Snapshots` ("latest = last computed"); the optional `repo` field fingerprints repository state (branch, HEAD sha, dirty counts) at write time so agents can detect staleness against `git rev-parse HEAD`; no history, no runtime integration — coding agents read the files directly ([ADR 020](./adr/020-latest-snapshot-contract.md)).
- `.meridian/AGENTS.md` — generated once on first snapshot write, documenting the `.meridian/latest/` contract and a paste-ready agent-rules snippet; never overwritten once present, safe to edit ([ADR 020](./adr/020-latest-snapshot-contract.md)).

---
Expand Down
33 changes: 33 additions & 0 deletions docs/adr/020-latest-snapshot-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,39 @@ new subsystem.
"updated Nm ago" row descriptions (redrawn via `onLatestSnapshotWrite`)
and never parses the JSON, so it cannot couple to the envelope shape.

## Addendum (2026-07-12) — repo fingerprint and headless refresh

Two additive extensions, both inside the v1 envelope contract (adding a
field is non-breaking; only removal or re-meaning forces `v2`):

1. **Envelope `repo` staleness fingerprint.** Each write stamps an optional
`repo: { branch, head, isDirty, staged, unstaged, untracked }` captured at
write time, resolved through an injectable provider registered at
activation from the GitProvider (this module still never imports `vscode`
or shells git). `generatedAt` only says *when* a snapshot was written;
`repo.head` lets an agent compare against `git rev-parse HEAD` and *know*
whether it predates recent commits. All-or-nothing and fail-soft: any
failed lookup omits the field entirely (absent = "not measured", never a
partial or fabricated fingerprint), and a provider failure can never lose
a snapshot.

2. **Second sanctioned write path: `meridian.latest.refresh`.** The original
decision's weakness was that snapshots only refreshed when a *human
rendered a webview* — an agent could only ever read state as fresh as the
last click. `Meridian: Refresh Latest Snapshots`
(`src/presentation/latest-refresh.ts`) dispatches the three report
commands through the router and writes each result through the same
`writeLatestSnapshot()` chokepoint, opening no panel. Point 3's semantics
widen from "latest = last rendered" to **"latest = last computed"**
(render or explicit refresh); the handlers return the exact object a
webview would receive, so the two write paths cannot diverge. This does
*not* reopen the ADR 012 runtime-surface question: it is a user-triggered
command (palette / keybinding / task), pull-only — no daemon, no watcher,
no LM tool.

`.meridian/AGENTS.md` documents both (new workspaces only — the file is
never overwritten once present, by design).

## Cross-references

- `src/infrastructure/latest-snapshot.ts` — `writeLatestSnapshot()`,
Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@
"title": "Reveal Latest Snapshots",
"icon": "$(folder-opened)",
"category": "Meridian"
},
{
"command": "meridian.latest.refresh",
"title": "Refresh Latest Snapshots",
"category": "Meridian"
}
],
"configuration": {
Expand Down Expand Up @@ -413,6 +418,10 @@
{
"command": "meridian.latest.reveal",
"when": "workspaceFolderCount > 0"
},
{
"command": "meridian.latest.refresh",
"when": "workspaceFolderCount > 0"
}
],
"view/title": [
Expand Down
4 changes: 4 additions & 0 deletions src/infrastructure/git-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,10 @@ class RealGitProvider implements GitProvider {
return result;
}

async getHeadCommit(): Promise<Result<string>> {
return git(["rev-parse", "HEAD"], this.workspaceRoot);
}

async getRecentCommits(count: number): Promise<Result<RecentCommit[]>> {
const logResult = await git(
["log", `-${count}`, "--pretty=format:%h|%s|%an", "--numstat"],
Expand Down
59 changes: 55 additions & 4 deletions src/infrastructure/latest-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,27 @@ export type LatestSnapshotKind = keyof typeof LATEST_SNAPSHOT_FILES;

export const LATEST_SNAPSHOT_SCHEMA_VERSION = 1;

/**
* Repository state captured at write time — the envelope's staleness
* fingerprint (ADR 020 addendum). An agent compares `head` against
* `git rev-parse HEAD` to detect a snapshot that predates recent commits.
* Declared explicitly (not derived from GitStatus) so the wire shape cannot
* silently widen if the internal type gains fields.
*/
export interface LatestSnapshotRepoContext {
branch: string;
head: string;
isDirty: boolean;
staged: number;
unstaged: number;
untracked: number;
}

interface LatestSnapshotEnvelope {
schemaVersion: typeof LATEST_SNAPSHOT_SCHEMA_VERSION;
kind: LatestSnapshotKind;
generatedAt: string;
repo?: LatestSnapshotRepoContext;
report: unknown;
}

Expand All @@ -52,16 +69,21 @@ off disk. No runtime integration or tool call is required.
Each file is a JSON envelope:

\`\`\`json
{ "schemaVersion": 1, "kind": "sessionBriefing", "generatedAt": "<ISO 8601>", "report": { ... } }
{ "schemaVersion": 1, "kind": "sessionBriefing", "generatedAt": "<ISO 8601>", "repo": { "branch": "main", "head": "<full sha>", "isDirty": false, "staged": 0, "unstaged": 0, "untracked": 0 }, "report": { ... } }
\`\`\`

## Semantics

- **Latest = last rendered.** Each file is overwritten in place whenever the
corresponding report webview renders (initial open, refresh, or filter).
There is no history — read history via \`.meridian/pulse/\` instead.
- **Latest = last computed.** Each file is overwritten in place whenever the
corresponding report is computed — a webview render (initial open, refresh,
or filter) or the \`Meridian: Refresh Latest Snapshots\` command. There is
no history — read history via \`.meridian/pulse/\` instead.
- **Freshness.** The envelope's \`generatedAt\` is the write time;
\`report.generatedAt\` (when present) is when the report was computed.
- **Staleness.** When present, \`repo\` captures repository state at write
time. Compare \`repo.head\` against \`git rev-parse HEAD\` to detect a
snapshot that predates recent commits; \`repo\` may be absent (not
measured), never fabricated.
- **Absent optional fields mean "not measured," never zero.** Do not treat a
missing field as a zero value.
- **Open sets.** Flag \`id\`s and \`severity\` values may gain members within
Expand Down Expand Up @@ -123,6 +145,23 @@ async function writeFileIfMissing(filePath: string, content: string, logger: Log
*/
let writeQueue: Promise<void> = Promise.resolve();

export type LatestSnapshotRepoContextProvider = () => Promise<LatestSnapshotRepoContext | null>;

let repoContextProvider: LatestSnapshotRepoContextProvider | null = null;

/**
* Register the resolver for the envelope's `repo` staleness fingerprint —
* wired once at activation from the GitProvider (this module stays
* `vscode`-free and never shells git itself). `null` (or a provider that
* resolves null, e.g. a non-git workspace) simply omits the field: absent
* means "not measured", per the envelope's fail-soft field semantics.
*/
export function setLatestSnapshotRepoContextProvider(
provider: LatestSnapshotRepoContextProvider | null
): void {
repoContextProvider = provider;
}

export type LatestSnapshotWriteListener = (kind: LatestSnapshotKind) => void;

const writeListeners = new Set<LatestSnapshotWriteListener>();
Expand Down Expand Up @@ -170,10 +209,22 @@ export function writeLatestSnapshot(
await writeFileIfMissing(path.join(latestDir, ".gitignore"), "*\n", logger);
await writeFileIfMissing(path.join(workspaceRoot, MERIDIAN_DIR, AGENTS_MD_FILENAME), AGENTS_MD_CONTENT, logger);

// Fingerprint failure is isolated like the side files: a broken provider
// degrades to an envelope without `repo`, never a lost snapshot.
let repo: LatestSnapshotRepoContext | null = null;
if (repoContextProvider) {
try {
repo = await repoContextProvider();
} catch (e) {
logger.warn(`Latest-snapshot repo context failed for ${kind}: ${String(e)}`, "writeLatestSnapshot");
}
}

const envelope: LatestSnapshotEnvelope = {
schemaVersion: LATEST_SNAPSHOT_SCHEMA_VERSION,
kind,
generatedAt: new Date().toISOString(),
...(repo ? { repo } : {}),
report,
};

Expand Down
113 changes: 96 additions & 17 deletions src/infrastructure/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,40 @@ export const SETTING_DEFAULTS = {
export type SettingKey = keyof typeof SETTING_DEFAULTS;
export type SettingValue<K extends SettingKey> = typeof SETTING_DEFAULTS[K];

/**
* What a (re)parse of `.meridian/settings.json` found wrong. All three are
* silently *ignored* at read time (fall-through semantics are the contract);
* the diagnostics exist so a typo'd key or mis-typed value is reported once
* instead of failing silently forever.
*/
export interface WorkspaceSettingsDiagnostics {
/** File-level failure: unreadable, malformed JSON, or not a JSON object. */
parseError?: string;
/** Keys with no SETTING_DEFAULTS counterpart (typo'd or retired). */
unknownKeys: string[];
/** Known keys whose value shape mismatches the typed default. */
mismatchedKeys: string[];
}

export type WorkspaceSettingsDiagnosticsListener = (
diagnostics: WorkspaceSettingsDiagnostics,
file: string
) => void;

let diagnosticsListener: WorkspaceSettingsDiagnosticsListener | null = null;

/**
* Register the sink for settings-file diagnostics (main.ts wires the Meridian
* output channel). Invoked at most once per file change — parsing is keyed to
* the file's mtime, so a hot readSetting() path never re-emits. Listener
* errors are swallowed; reporting must never break a settings read.
*/
export function setWorkspaceSettingsDiagnosticsListener(
listener: WorkspaceSettingsDiagnosticsListener | null
): void {
diagnosticsListener = listener;
}

let workspaceSettingsCache:
| { mtime: number; root: string; values: Record<string, unknown> }
| null = null;
Expand All @@ -54,32 +88,77 @@ function vscodeWorkspaceRoot(): string | undefined {
}
}

/** Key-level audit of a parsed overrides object (file-level errors are handled by the caller). */
function diagnoseOverrides(values: Record<string, unknown>): WorkspaceSettingsDiagnostics {
const unknownKeys: string[] = [];
const mismatchedKeys: string[] = [];
for (const [key, value] of Object.entries(values)) {
if (!(key in SETTING_DEFAULTS)) {
unknownKeys.push(key);
} else if (value !== null && !overrideMatchesDefault(value, key as SettingKey)) {
// JSON null is the documented "not supplied" idiom — never flagged.
mismatchedKeys.push(key);
}
}
return { unknownKeys, mismatchedKeys };
}

function emitDiagnostics(diagnostics: WorkspaceSettingsDiagnostics, file: string): void {
if (!diagnostics.parseError && diagnostics.unknownKeys.length === 0 && diagnostics.mismatchedKeys.length === 0) {
return;
}
try {
diagnosticsListener?.(diagnostics, file);
} catch {
// A broken listener must never break a settings read.
}
}

function readWorkspaceSettings(): Record<string, unknown> {
const root = vscodeWorkspaceRoot();
if (!root) return {};
const file = path.join(root, MERIDIAN_DIR, "settings.json");

// An absent file is the normal case: no overrides, no diagnostic.
let stat: fs.Stats;
try {
const stat = fs.statSync(file);
if (
workspaceSettingsCache &&
workspaceSettingsCache.root === root &&
workspaceSettingsCache.mtime === stat.mtimeMs
) {
return workspaceSettingsCache.values;
}
const raw = fs.readFileSync(file, "utf-8");
const parsed: unknown = JSON.parse(raw);
const values: Record<string, unknown> =
typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
workspaceSettingsCache = { mtime: stat.mtimeMs, root, values };
return values;
stat = fs.statSync(file);
} catch {
// Missing file, malformed JSON, ENOENT — all collapse to "no overrides".
workspaceSettingsCache = null;
return {};
}

if (
workspaceSettingsCache &&
workspaceSettingsCache.root === root &&
workspaceSettingsCache.mtime === stat.mtimeMs
) {
return workspaceSettingsCache.values;
}

// Cache miss = the file changed (or first read): parse once, audit once.
// A failed parse is cached against the same mtime so a malformed file is
// neither re-parsed nor re-reported on every readSetting() call.
let values: Record<string, unknown> = {};
let diagnostics: WorkspaceSettingsDiagnostics;
try {
const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8"));
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
values = parsed as Record<string, unknown>;
diagnostics = diagnoseOverrides(values);
} else {
diagnostics = { parseError: "not a JSON object of key/value pairs", unknownKeys: [], mismatchedKeys: [] };
}
} catch (e) {
diagnostics = {
parseError: e instanceof Error ? e.message : String(e),
unknownKeys: [],
mismatchedKeys: [],
};
}
workspaceSettingsCache = { mtime: stat.mtimeMs, root, values };
emitDiagnostics(diagnostics, file);
return values;
}

// Shape check against the typed default. Rejects mismatched workspace-file
Expand Down
Loading
Loading