diff --git a/CHANGELOG.md b/CHANGELOG.md
index 607e61a..d23ae30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,51 @@ All notable changes to **tre-mem** are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.9.0] — 2026-06-07
+
+**The Grove — a second-brain contributor graph, plus a Vietnamese dashboard.** A new
+`tre web` tab visualizes the team's shared knowledge as an Obsidian-style force
+graph (trunk + branches + contributors + facts) and ranks who grew it, and the
+whole dashboard now speaks Vietnamese with a Vietnamese-friendly type system.
+
+### Added
+
+- **Grove tab** in the dashboard: an interactive `d3-force` + canvas graph of the
+ repo's shared memory. The project trunk (`root`), each branch, each contributor,
+ and each fact (pins = young shoots, graduated facts = mature culms) are nodes;
+ edges wire authorship, where a fact lives, and how branches graduate into the
+ trunk. Bamboo palette driven by the existing theme tokens (light/dark aware).
+ Hover for a tooltip; click a branch/fact to drill into Branch detail, click a
+ contributor to highlight their shoots.
+- **Contributor leaderboard** with a weighted value score (branch-local pins ×1,
+ graduated/rooted facts ×3) plus playful badges & streaks — Gardener of the week,
+ Most rooted, Longest streak, First sprout.
+- **Shareable grove card**: one click exports a bamboo-framed PNG of your grove's
+ headline stats — the viral, post-it-anywhere artifact.
+- **Growth time-lapse**: scrub or play back how the grove grew over time.
+- **Solo/unshared fallback**: when nothing has been shared yet, the grove backfills
+ contributors from `git log` authors per branch (flagged as `git-fallback`) so a
+ fresh repo is never empty. Toggle with `?fallback=` on the new endpoints.
+- New read-only endpoints `GET /api/contributors` and `GET /api/graph`
+ (project-scoped), plus a read-only `.tre-mem/` JSONL reader (`src/sync/read.ts`)
+ — contributor attribution lives in the committed JSONL `author` field, so no
+ schema migration was needed.
+- **Vietnamese (VI) localization** of the entire dashboard with an EN ⇄ VI toggle
+ (persisted, auto-detects Vietnamese browsers) and a deliberately casual, fun
+ Vietnamese voice. `timeAgo` is localized too.
+
+### Changed
+
+- **Type system → Vietnamese-friendly fonts.** Display/headings now use Baloo 2
+ (cute, rounded) and body uses Be Vietnam Pro (a Vietnamese-native typeface),
+ both with full diacritics and system fallbacks — fixing the diacritic stacking
+ the previous serif had on Vietnamese text.
+
+### Fixed
+
+- Dashboard shell grid declared two rows for three children, so the nav row
+ ballooned and pushed content down on low-content pages; now `auto auto 1fr`.
+
## [0.8.0] — 2026-06-07
**Bamboo — the green identity.** `tre` means _bamboo_ in Vietnamese, but the tool
diff --git a/PLAN-PHASE6.md b/PLAN-PHASE6.md
new file mode 100644
index 0000000..fcd7ee4
--- /dev/null
+++ b/PLAN-PHASE6.md
@@ -0,0 +1,49 @@
+# tre-mem Phase 6 — "The Grove" (contributor graph, leaderboard & viral share)
+
+> Sibling plan to [`PLAN.md`](./PLAN.md) (v0.1), [`PLAN-PHASE2.md`](./PLAN-PHASE2.md) (v0.2–v0.4),
+> [`PLAN-PHASE3.md`](./PLAN-PHASE3.md) (v0.5), [`PLAN-PHASE4.md`](./PLAN-PHASE4.md) (v0.6),
+> [`PLAN-PHASE5.md`](./PLAN-PHASE5.md) (v0.7.x). This file is the **Phase 6 SSOT** (v0.9.0;
+> rebased onto the v0.8.0 bamboo identity).
+
+## Context
+
+Through v0.7, tre-mem makes a team's git-shared memory _legible_ (`tre web`: Overview / Team
+memory / Search). Phase 6 makes it **emotional and viral**: a second-brain, Obsidian-style force
+graph of the repo — the trunk (graduated repo-wide facts), the branches, and the **contributors**
+who grew them — plus a "best contributor" leaderboard with playful bamboo badges and a one-click
+shareable grove card.
+
+**Why it was feasible with no migration:** every committed `.tre-mem/` JSONL record
+(`PinRecord` / `GraduatedRecord` in `src/sync/format.ts`) already carries `author`, populated from
+`git config user.name` at `tre share` / graduate time. The sidecar DB has no author column and the
+web layer read only the DB — so the new feature reads the JSONL directly via a new read-only reader.
+
+**Decisions locked with owner:** ship all four surfaces (graph, leaderboard, share card, time-lapse);
+solo/unshared repos fall back to `git log` authors so the grove is never empty; full gamification
+(value score + badges & streaks).
+
+## Tasks
+
+- [x] **Read-only JSONL reader** — `src/sync/read.ts` (`readSyncRecords` / `readSyncDir`), reusing
+ `parseSyncLine` + layout helpers. Test: `test/sync-read.test.ts`.
+- [x] **Grove core** — `src/web/grove.ts`: `aggregateContributors` (value score = pins×1 +
+ graduated×3, badges/streaks), `buildGraph`, `buildGitContributors` + `buildGitGraph` (fallback).
+ `branchAuthors()` git helper in `src/git/identity.ts`. Test: `test/web-grove.test.ts`.
+- [x] **Endpoints** — `GET /api/contributors` and `GET /api/graph` added to `ROUTES` in
+ `src/web/api.ts`; project-scoped; `?fallback=git` (default on) when nothing is attributed.
+- [x] **Grove tab + leaderboard** — `web/lib.tsx` types, `web/app.tsx` tab, `web/views/Grove.tsx` +
+ `Leaderboard.tsx` (badges, drill into Branch detail).
+- [x] **Graph canvas** — `web/views/GraphCanvas.tsx`: `d3-force` simulation + HTML canvas, theme
+ tokens, bamboo sizing, hover tooltip, click-to-drill, `cutoffEpoch` for time-lapse.
+- [x] **Share card + time-lapse** — `web/views/ShareCard.tsx` (canvas → PNG) and the time-lapse
+ scrubber/playback in `Grove.tsx`.
+- [x] **Docs + release** — `docs/WEB-UI.md` (Grove tab + endpoints), `CHANGELOG.md` `[0.9.0]`,
+ version bump `package.json` + `src/version.ts` → `0.9.0`.
+
+## Notes
+
+- Graph rendering uses `d3-force` (force sim only) + a hand-rolled canvas renderer — chosen over
+ cytoscape (~350 KB) and react-force-graph-2d (~80–120 KB) to honor the dependency-light ethos.
+ Bundle add ≈ 13 KB; SPA raw ≈ 243 KB.
+- Edge kinds: `authored` (contributor→fact), `lives_on` (fact→branch), `graduates_into`
+ (branch→root), `committed` (contributor→branch, git-fallback only).
diff --git a/PLAN.md b/PLAN.md
index 31cf146..9d11b1b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -9,7 +9,8 @@
> [`PLAN-PHASE2.md`](./PLAN-PHASE2.md) — Phase 2 (v0.2–v0.4.x, git-native team share) ·
> [`PLAN-PHASE3.md`](./PLAN-PHASE3.md) — Phase 3 (v0.5.x, team web dashboard) ·
> [`PLAN-PHASE4.md`](./PLAN-PHASE4.md) — Phase 4 (v0.6.x, cross-tool port) ·
-> [`PLAN-PHASE5.md`](./PLAN-PHASE5.md) — Phase 5 (v0.7.x, "Share, made obvious"). Current: **v0.7.0**.
+> [`PLAN-PHASE5.md`](./PLAN-PHASE5.md) — Phase 5 (v0.7.x, "Share, made obvious") ·
+> [`PLAN-PHASE6.md`](./PLAN-PHASE6.md) — Phase 6 (v0.9.x, "The Grove" — contributor graph + VN i18n). Current: **v0.9.0**.
---
@@ -387,3 +388,4 @@ Parser: `cac` (nhẹ, đủ).
- **2026-05-31** — T2D10 polish (docs + version) done. Added `README.md` (one-liner + why + 3-step install + MCP registration via `claude mcp add -s user tre-mem -- tre mcp` + SessionStart hook + CLI surface + MCP tool table + architecture diagram + status), `CHANGELOG.md` in Keep-a-Changelog format with a single `[0.1.0] — 2026-05-31` entry covering sidecar store, read-only adapter, branch resolver/watcher, reflog backfill, 3-signal rerank, MCP server, SessionStart hook, CLI, benchmark harness, docs. Added MIT `LICENSE`. Bumped `package.json` version `0.0.0 → 0.1.0` and synced `getPackageVersion()` in `src/cli.ts` so `tre --version` prints `tre/0.1.0`. All quality gates remain green (typecheck + lint + 96/96 tests + build). `npm publish v0.1.0`, demo screen-record, and external-dev validation deliberately deferred to user action — those require credentials / hands / a second human and cannot be automated from this session.
- **2026-06-07** — v0.8.0 "Bamboo — the green identity" done. Established a design-system SSOT (`docs/BRAND.md`) and made tre actually look like bamboo on both surfaces a user sees. **Web** (`web/styles.css`): full re-theme — primary `--bark` → `--bamboo` (amber → jade green), green-tinted rice-paper/ink ramp, semantic spines re-hued into a coordinated grove palette (sky / young-shoot / lacquer-gold / clay-red), both light + dark intentional; token-only change, no component restructure, JS bundle unchanged; added a bamboo-culm `BambooMark` SVG before the wordmark + a 🎋 favicon. **Terminal** (`src/format/`): `colors.ts` gained a `brand()` helper + `BAMBOO` motif; the SessionStart digest header (`🎋 tre-mem · recent context`), live-dashboard link, and pinned-section header now read green; `tre web` / `tre doctor` carry the 🎋 mark; the plain model-facing `context` string stays ANSI-free. Docs synced (README + README.vi "Brand & design", WEB-UI theme note, PLAN-PHASE3 design-direction update); version `0.7.1 → 0.8.0` across `package.json` + `src/version.ts` + CHANGELOG. Decision was user-confirmed: full web re-theme, subtle terminal (no banner), minor bump.
- **2026-05-31** — T2D8 done. `src/mcp/tools.ts` exposes 5 pure tool functions (`getBranchContext`, `getBranchTimeline`, `listBranches`, `pinFact`, `graduateFact`) plus a `callTool(name, args)` dispatcher and a frozen `TOOL_DEFINITIONS` array carrying name/description/JSON-Schema for each. Tools resolve `project`/`branch` defaults via injectable `ToolDeps.defaultCwd` + `ToolDeps.resolveBranch` (defaults to `process.cwd()` + `currentBranch()`), and `now` is injectable for deterministic tests. `src/mcp/server.ts` uses the lower-level `@modelcontextprotocol/sdk` `Server` (not `McpServer`) because we don't want a zod dependency: `ListToolsRequestSchema` returns `TOOL_DEFINITIONS` 1:1; `CallToolRequestSchema` dispatches to `callTool`, JSON-stringifies into `content[0].text` AND mirrors structured into `structuredContent` so both old + new MCP clients work, and converts thrown errors to `{isError: true}`. `runMcpServer()` runs `migrate()`, opens adapter + repo, wires `StdioServerTransport`, and closes handles on SIGINT/SIGTERM. CLI gains `tre mcp` command. **Live MCP handshake smoke (real `~/.tre-mem/tre-mem.db`)**: `initialize` → `notifications/initialized` → `tools/list` returns all 5 tool descriptors; `tools/call list_branches {project:"tre-mem"}` returns `{branches: [{branch:"main", count:22}]}` — i.e. the same 22-tag count `tre status` shows, proving the full stack works through stdio. 10 new tests (`mcp-tools.test.ts` × 9, `mcp-server.test.ts` × 1); suite 96/96 green; lint + typecheck + build clean.
+- **2026-06-07** — **Phase 6 "The Grove" (v0.8.0) done.** New `tre web` Grove tab: an Obsidian-style `d3-force` + canvas graph of the repo's shared memory (root/trunk + branches + contributors + facts) beside a contributor leaderboard. Built on the realization that contributor identity already lives in the committed `.tre-mem/` JSONL `author` field (set at `tre share`/graduate time) — so a new read-only reader (`src/sync/read.ts`) feeds pure aggregators in `src/web/grove.ts` (value score = pins×1 + graduated×3, plus Gardener-of-the-week / Most-rooted / Longest-streak / First-sprout badges) with **no schema migration**. Two read-only endpoints `/api/contributors` + `/api/graph` (project-scoped, `?fallback=git` defaulting on so solo/unshared repos backfill contributors from `git log` authors). Frontend: `Grove.tsx` + `Leaderboard.tsx` + `GraphCanvas.tsx` (theme-token colors, bamboo node sizing, hover/click drill into Branch detail) + `ShareCard.tsx` (canvas→PNG) + growth time-lapse scrubber. `d3-force` (sim only) + hand-rolled canvas chosen over cytoscape/react-force-graph to stay dependency-light (~13 KB add). Also full **Vietnamese i18n** (EN/VI toggle, casual voice) + a VN-friendly type system (Baloo 2 + Be Vietnam Pro). Tests: `test/sync-read.test.ts`, `test/web-grove.test.ts`. Rebased onto v0.8.0 bamboo identity (`--bark` → `--bamboo`); version → 0.9.0; CHANGELOG `[0.9.0]`; SSOT `PLAN-PHASE6.md`.
diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md
index 57f2f36..66ff9a1 100644
--- a/docs/WEB-UI.md
+++ b/docs/WEB-UI.md
@@ -49,6 +49,15 @@ in `~/.tre-mem/web.pid` (self-healing: a stale pidfile is cleared automatically)
- **Overview** — a branch graph: every branch with its tagged-observation count,
pin count, last-active time, and the current `HEAD` highlighted. Click a branch
to drill in.
+- **Grove** — the second-brain view: an Obsidian-style force graph of the repo's
+ shared memory (the project trunk + branches + contributors + facts) beside a
+ contributor leaderboard. Facts are sized as young shoots (pins) and mature culms
+ (graduated facts); contributor node size tracks their value score (pins ×1,
+ graduated ×3). Earns playful badges (Gardener of the week, Most rooted, Longest
+ streak, First sprout), exports a shareable PNG card, and replays grove growth
+ over time. Hover for tooltips; click a branch/fact to drill into **Branch
+ detail**, click a contributor to highlight their shoots. When nothing has been
+ shared yet, it falls back to `git log` authors so the grove is never empty.
- **Branch detail** — pinned decisions, facts graduated from that branch, and the
tagged activity timeline.
- **Team memory** — the point of the dashboard: every pinned decision and
@@ -97,6 +106,8 @@ The SPA is served over a dependency-light `node:http` server. Endpoints, all
| `/api/pins` | all pins for the project |
| `/api/graduated` | all graduated facts for the project |
| `/api/share-status` | pending export / shared / graduated counts |
+| `/api/contributors` | contributor leaderboard (value score + badges) |
+| `/api/graph` | force-graph nodes + edges for the Grove view |
| `/api/search?q=&branch=` | branch-aware search (full or degraded) |
| `/api/observation/:id` | observation detail (`full` mode only) |
| `/api/events` | SSE live-update stream |
diff --git a/package.json b/package.json
index 5c56b28..b182669 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "tre-mem",
- "version": "0.8.0",
+ "version": "0.9.0",
"description": "Branch-aware shared memory layer for AI coding tools. Sidecar on top of claude-mem.",
"keywords": [
"claude-code",
@@ -53,11 +53,13 @@
"cac": "^7.0.0",
"chokidar": "^4.0.3",
"chromadb": "^3.4.3",
+ "d3-force": "^3.0.0",
"picocolors": "^1.1.1",
"simple-git": "^3.36.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
+ "@types/d3-force": "^3.0.10",
"@types/node": "^22.10.0",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e6a4cea..eaa6a1c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
chromadb:
specifier: ^3.4.3
version: 3.4.3
+ d3-force:
+ specifier: ^3.0.0
+ version: 3.0.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -33,6 +36,9 @@ importers:
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
+ '@types/d3-force':
+ specifier: ^3.0.10
+ version: 3.0.10
'@types/node':
specifier: ^22.10.0
version: 22.19.19
@@ -434,6 +440,9 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+ '@types/d3-force@3.0.10':
+ resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -703,6 +712,22 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-force@3.0.0:
+ resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+ engines: {node: '>=12'}
+
+ d3-quadtree@3.0.1:
+ resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1818,6 +1843,8 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
+ '@types/d3-force@3.0.10': {}
+
'@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {}
@@ -2115,6 +2142,18 @@ snapshots:
csstype@3.2.3: {}
+ d3-dispatch@3.0.1: {}
+
+ d3-force@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-quadtree: 3.0.1
+ d3-timer: 3.0.1
+
+ d3-quadtree@3.0.1: {}
+
+ d3-timer@3.0.1: {}
+
debug@4.4.3:
dependencies:
ms: 2.1.3
diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs
index 92ef722..c91d230 100644
--- a/scripts/build-web.mjs
+++ b/scripts/build-web.mjs
@@ -38,6 +38,12 @@ const html = `
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ctext y='20' font-size='20'%3E%F0%9F%8E%8B%3C/text%3E%3C/svg%3E"
/>
+
+
+
diff --git a/src/git/identity.ts b/src/git/identity.ts
index f776b89..6d709ee 100644
--- a/src/git/identity.ts
+++ b/src/git/identity.ts
@@ -14,3 +14,30 @@ export async function gitAuthor(cwd: string): Promise {
return null;
}
}
+
+/**
+ * Commit authors on a branch, most-frequent first, with their commit counts.
+ * Used as the solo/unshared fallback for the Grove view: when nothing has been
+ * shared yet, contributors are inferred from `git log --format=%an`. Best-effort
+ * — returns `[]` on any failure (no repo, unknown branch). Never throws.
+ */
+export async function branchAuthors(
+ cwd: string,
+ branch: string,
+): Promise> {
+ if (!existsSync(cwd)) return [];
+ try {
+ const raw = await simpleGit(cwd).raw(['log', '--format=%an', branch]);
+ const counts = new Map();
+ for (const line of raw.split('\n')) {
+ const name = line.trim();
+ if (name === '') continue;
+ counts.set(name, (counts.get(name) ?? 0) + 1);
+ }
+ return [...counts.entries()]
+ .map(([name, commits]) => ({ name, commits }))
+ .sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name));
+ } catch {
+ return [];
+ }
+}
diff --git a/src/sync/read.ts b/src/sync/read.ts
new file mode 100644
index 0000000..1e27ad3
--- /dev/null
+++ b/src/sync/read.ts
@@ -0,0 +1,56 @@
+import { existsSync, readFileSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { parseSyncLine, type GraduatedRecord, type PinRecord, type SyncRecord } from './format.js';
+import { graduatedFilePath } from './layout.js';
+
+/**
+ * Enumerate the JSONL files in a committed `.tre-mem/` directory: the single
+ * `graduated.jsonl` plus every `branches/*.jsonl`, in a stable sorted order.
+ */
+function listJsonlFiles(dir: string): string[] {
+ const files: string[] = [];
+ const graduated = graduatedFilePath(dir);
+ if (existsSync(graduated)) files.push(graduated);
+ const branchesDir = join(dir, 'branches');
+ if (existsSync(branchesDir)) {
+ for (const name of readdirSync(branchesDir).sort()) {
+ if (name.endsWith('.jsonl')) files.push(join(branchesDir, name));
+ }
+ }
+ return files;
+}
+
+/**
+ * Read every shared pin + graduated record out of a committed `.tre-mem/`
+ * directory. Read-only counterpart to `importDir` (no DB writes, no SHA state):
+ * the web layer uses this to surface the `author` field — which lives only in
+ * the JSONL, never in the sidecar DB. Malformed / newer-schema lines are skipped
+ * rather than aborting the read. A missing directory yields an empty list.
+ */
+export function readSyncRecords(dir: string): SyncRecord[] {
+ if (!existsSync(dir)) return [];
+ const records: SyncRecord[] = [];
+ for (const filePath of listJsonlFiles(dir)) {
+ for (const line of readFileSync(filePath, 'utf8').split('\n')) {
+ if (line.trim() === '') continue;
+ try {
+ records.push(parseSyncLine(line));
+ } catch {
+ /* skip unparseable / newer-schema lines, same as importDir */
+ }
+ }
+ }
+ return records;
+}
+
+/** Same read, split into the two record kinds for callers that want them apart. */
+export function readSyncDir(dir: string): { pins: PinRecord[]; graduated: GraduatedRecord[] } {
+ const pins: PinRecord[] = [];
+ const graduated: GraduatedRecord[] = [];
+ for (const record of readSyncRecords(dir)) {
+ if (record.kind === 'pin') pins.push(record);
+ else graduated.push(record);
+ }
+ return { pins, graduated };
+}
diff --git a/src/version.ts b/src/version.ts
index a42d45e..b32f48e 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -4,4 +4,4 @@
* Imported by the CLI (`tre --version`), the MCP server handshake, and the
* session digest's compatibility warnings.
*/
-export const VERSION = '0.8.0';
+export const VERSION = '0.9.0';
diff --git a/src/web/api.ts b/src/web/api.ts
index 978408d..a5b7c51 100644
--- a/src/web/api.ts
+++ b/src/web/api.ts
@@ -2,10 +2,19 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Observation } from '../adapter/types.js';
+import { branchAuthors } from '../git/identity.js';
import { searchBranchContext } from '../retrieval/search.js';
import type { BranchPin, Graduated, TreMemRepo } from '../store/repo.js';
import { SYNC_DIR_NAME } from '../sync/layout.js';
+import { readSyncRecords } from '../sync/read.js';
+import {
+ aggregateContributors,
+ buildGitContributors,
+ buildGitGraph,
+ buildGraph,
+ type BranchAuthors,
+} from './grove.js';
import { sendError, sendJson } from './respond.js';
import { type Route, type RouteCtx, webMode } from './types.js';
@@ -68,6 +77,27 @@ function snippet(text: string | null, max = 200): string | null {
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
+/** `?fallback=git` (default on) lets solo/unshared repos light up via git authors. */
+function fallbackEnabled(ctx: RouteCtx): boolean {
+ const q = ctx.query.get('fallback');
+ return q === null || q === '' || q === 'git' || q === '1' || q === 'true';
+}
+
+/** Commit authors for every branch tre-mem knows about — the git-fallback source. */
+async function gitBranchAuthors(
+ repo: TreMemRepo,
+ cwd: string,
+ project: string,
+): Promise {
+ const branches = repo.listBranchesForProject(project).map((b) => b.branch);
+ const out: BranchAuthors[] = [];
+ for (const branch of branches) {
+ const authors = await branchAuthors(cwd, branch);
+ if (authors.length > 0) out.push({ branch, authors });
+ }
+ return out;
+}
+
const ROUTES: Route[] = [
{
method: 'GET',
@@ -243,6 +273,73 @@ const ROUTES: Route[] = [
});
},
},
+ {
+ method: 'GET',
+ pattern: '/api/contributors',
+ handler: async (_req, res, ctx) => {
+ const project = resolveProject(ctx);
+ const dir = join(ctx.deps.cwd, SYNC_DIR_NAME);
+ const records = readSyncRecords(dir);
+ const { contributors, attributed_total, unattributed_total } = aggregateContributors(
+ records,
+ project,
+ ctx.deps.now(),
+ );
+ if (attributed_total === 0 && fallbackEnabled(ctx)) {
+ const perBranch = await gitBranchAuthors(ctx.deps.repo, ctx.deps.cwd, project);
+ if (perBranch.length > 0) {
+ return sendJson(res, 200, {
+ project,
+ source: 'git-fallback',
+ contributors: buildGitContributors(perBranch, ctx.deps.now()),
+ attributed_total: 0,
+ unattributed_total,
+ has_sync_dir: existsSync(dir),
+ });
+ }
+ }
+ return sendJson(res, 200, {
+ project,
+ source: 'shared',
+ contributors,
+ attributed_total,
+ unattributed_total,
+ has_sync_dir: existsSync(dir),
+ });
+ },
+ },
+ {
+ method: 'GET',
+ pattern: '/api/graph',
+ handler: async (_req, res, ctx) => {
+ const project = resolveProject(ctx);
+ const dir = join(ctx.deps.cwd, SYNC_DIR_NAME);
+ const records = readSyncRecords(dir);
+ const hasFacts = records.some((r) => r.project === project);
+ if (!hasFacts && fallbackEnabled(ctx)) {
+ const perBranch = await gitBranchAuthors(ctx.deps.repo, ctx.deps.cwd, project);
+ if (perBranch.length > 0) {
+ const { nodes, edges } = buildGitGraph(perBranch, project);
+ return sendJson(res, 200, {
+ project,
+ source: 'git-fallback',
+ nodes,
+ edges,
+ has_sync_dir: existsSync(dir),
+ });
+ }
+ }
+ const extraBranches = ctx.deps.repo.listBranchesForProject(project).map((b) => b.branch);
+ const { nodes, edges } = buildGraph(records, extraBranches, project);
+ return sendJson(res, 200, {
+ project,
+ source: 'shared',
+ nodes,
+ edges,
+ has_sync_dir: existsSync(dir),
+ });
+ },
+ },
];
/**
diff --git a/src/web/grove.ts b/src/web/grove.ts
new file mode 100644
index 0000000..837966a
--- /dev/null
+++ b/src/web/grove.ts
@@ -0,0 +1,368 @@
+import type { SyncRecord } from '../sync/format.js';
+
+/**
+ * Grove = the contributor view of a repo's shared memory. Pure, HTTP-free
+ * helpers so the aggregation, scoring, and graph-building can be unit-tested
+ * directly. Contributor identity lives only in the committed `.tre-mem/` JSONL
+ * (`author` per record) — never in the sidecar DB — so everything here reads
+ * `SyncRecord`s produced by `readSyncRecords`.
+ */
+
+/** Branch-local pins count 1×; graduated (repo-wide, survived a merge) count 3×. */
+export const PIN_WEIGHT = 1;
+export const GRADUATED_WEIGHT = 3;
+
+const WEEK_SECONDS = 7 * 24 * 60 * 60;
+const DAY_SECONDS = 24 * 60 * 60;
+
+/** Bucket label for records whose author was never recorded (shared pre-attribution). */
+export const UNATTRIBUTED = '(unattributed)';
+
+export type BadgeId = 'gardener_of_week' | 'most_rooted' | 'longest_streak' | 'first_sprout';
+
+export type GroveSource = 'shared' | 'git-fallback';
+
+export interface ContributorStat {
+ author: string;
+ /** false only for the synthetic UNATTRIBUTED bucket. */
+ attributed: boolean;
+ pins: number;
+ graduated: number;
+ facts_total: number;
+ /** Commits on touched branches — populated only in git-fallback mode. */
+ commits: number;
+ branches: string[];
+ branches_touched: number;
+ first_activity_epoch: number;
+ last_activity_epoch: number;
+ /** Longest run of consecutive UTC days with ≥1 contribution. */
+ streak_days: number;
+ value_score: number;
+ breakdown: { pin: number; graduated: number };
+ badges: BadgeId[];
+}
+
+export interface ContributorsResponse {
+ project: string;
+ source: GroveSource;
+ contributors: ContributorStat[];
+ attributed_total: number;
+ unattributed_total: number;
+ has_sync_dir: boolean;
+}
+
+export type GraphNodeKind = 'root' | 'branch' | 'contributor' | 'fact';
+
+export interface GraphNode {
+ id: string;
+ kind: GraphNodeKind;
+ label: string;
+ factKind?: 'pin' | 'graduated';
+ author?: string | null;
+ branch?: string;
+ observation_id?: number | null;
+ epoch?: number;
+ /** Relative sizing hint: contributor = value_score, branch = fact/commit count. */
+ weight?: number;
+}
+
+export interface GraphEdge {
+ source: string;
+ target: string;
+ kind: 'authored' | 'lives_on' | 'graduates_into' | 'committed';
+}
+
+export interface GraphResponse {
+ project: string;
+ source: GroveSource;
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+ has_sync_dir: boolean;
+}
+
+interface Acc {
+ author: string;
+ attributed: boolean;
+ pins: number;
+ graduated: number;
+ branches: Set;
+ epochs: number[];
+}
+
+function recordEpoch(r: SyncRecord): number {
+ return r.kind === 'pin' ? r.tagged_at_epoch : r.graduated_at_epoch;
+}
+
+function recordBranch(r: SyncRecord): string {
+ return r.kind === 'pin' ? r.branch : r.graduated_from_branch;
+}
+
+/** Longest run of consecutive UTC days that appears in the epoch list. */
+function longestStreakDays(epochs: number[]): number {
+ if (epochs.length === 0) return 0;
+ const days = [...new Set(epochs.map((e) => Math.floor(e / DAY_SECONDS)))].sort((a, b) => a - b);
+ let best = 1;
+ let run = 1;
+ for (let i = 1; i < days.length; i += 1) {
+ run = days[i] === (days[i - 1] as number) + 1 ? run + 1 : 1;
+ if (run > best) best = run;
+ }
+ return best;
+}
+
+/**
+ * Roll up the shared records into a per-author leaderboard, then assign the
+ * playful "awards" (each badge goes to at most one contributor). Sorted by
+ * value_score desc, tie-broken by most-recent activity.
+ */
+export function aggregateContributors(
+ records: SyncRecord[],
+ project: string,
+ now: number,
+): { contributors: ContributorStat[]; attributed_total: number; unattributed_total: number } {
+ const scoped = records.filter((r) => r.project === project);
+ const accs = new Map();
+ let attributed = 0;
+ let unattributed = 0;
+
+ for (const r of scoped) {
+ const attributedRow = r.author !== null && r.author.trim() !== '';
+ if (attributedRow) attributed += 1;
+ else unattributed += 1;
+ const author = attributedRow ? (r.author as string) : UNATTRIBUTED;
+ let acc = accs.get(author);
+ if (!acc) {
+ acc = {
+ author,
+ attributed: attributedRow,
+ pins: 0,
+ graduated: 0,
+ branches: new Set(),
+ epochs: [],
+ };
+ accs.set(author, acc);
+ }
+ if (r.kind === 'pin') acc.pins += 1;
+ else acc.graduated += 1;
+ acc.branches.add(recordBranch(r));
+ acc.epochs.push(recordEpoch(r));
+ }
+
+ const contributors: ContributorStat[] = [...accs.values()].map((a) => {
+ const value_score = a.pins * PIN_WEIGHT + a.graduated * GRADUATED_WEIGHT;
+ return {
+ author: a.author,
+ attributed: a.attributed,
+ pins: a.pins,
+ graduated: a.graduated,
+ facts_total: a.pins + a.graduated,
+ commits: 0,
+ branches: [...a.branches].sort((x, y) => x.localeCompare(y)),
+ branches_touched: a.branches.size,
+ first_activity_epoch: Math.min(...a.epochs),
+ last_activity_epoch: Math.max(...a.epochs),
+ streak_days: longestStreakDays(a.epochs),
+ value_score,
+ breakdown: { pin: a.pins, graduated: a.graduated },
+ badges: [],
+ };
+ });
+
+ assignBadges(contributors, accs, now);
+ contributors.sort(
+ (a, b) => b.value_score - a.value_score || b.last_activity_epoch - a.last_activity_epoch,
+ );
+ return { contributors, attributed_total: attributed, unattributed_total: unattributed };
+}
+
+function assignBadges(contributors: ContributorStat[], accs: Map, now: number): void {
+ const eligible = contributors.filter((c) => c.attributed);
+ if (eligible.length === 0) return;
+ const byAuthor = new Map(contributors.map((c) => [c.author, c]));
+ const award = (winner: ContributorStat | undefined, badge: BadgeId): void => {
+ if (winner) winner.badges.push(badge);
+ };
+
+ // Gardener of the week — most contributions in the last 7 days.
+ const weeklyScore = new Map();
+ for (const acc of accs.values()) {
+ if (!acc.attributed) continue;
+ const recent = acc.epochs.filter((e) => now - e <= WEEK_SECONDS).length;
+ if (recent > 0) weeklyScore.set(acc.author, recent);
+ }
+ const topWeekly = [...weeklyScore.entries()].sort((a, b) => b[1] - a[1])[0];
+ award(topWeekly ? byAuthor.get(topWeekly[0]) : undefined, 'gardener_of_week');
+
+ // Most rooted — most graduated facts (must have at least one).
+ const rooted = eligible
+ .filter((c) => c.graduated > 0)
+ .sort((a, b) => b.graduated - a.graduated || b.value_score - a.value_score)[0];
+ award(rooted, 'most_rooted');
+
+ // Longest streak — must be a real run (≥2 days).
+ const streak = [...eligible].sort((a, b) => b.streak_days - a.streak_days)[0];
+ if (streak && streak.streak_days >= 2) award(streak, 'longest_streak');
+
+ // First sprout — earliest contributor.
+ const firstSprout = [...eligible].sort(
+ (a, b) => a.first_activity_epoch - b.first_activity_epoch,
+ )[0];
+ award(firstSprout, 'first_sprout');
+}
+
+/**
+ * Build the force-graph from shared records. Node kinds: a single `root` (the
+ * project trunk), one `branch` per branch, one `contributor` per distinct
+ * author, and one `fact` per record. Edges wire authorship, where a fact lives,
+ * and how branches graduate into the trunk.
+ */
+export function buildGraph(
+ records: SyncRecord[],
+ extraBranches: string[],
+ project: string,
+): { nodes: GraphNode[]; edges: GraphEdge[] } {
+ const scoped = records.filter((r) => r.project === project);
+ const nodes: GraphNode[] = [{ id: 'root', kind: 'root', label: project }];
+ const edges: GraphEdge[] = [];
+
+ const branchFacts = new Map();
+ const contributorScore = new Map();
+ const seenFact = new Set();
+
+ for (const r of scoped) {
+ const branch = recordBranch(r);
+ branchFacts.set(branch, (branchFacts.get(branch) ?? 0) + 1);
+ const factId = `fact:${r.content_hash}`;
+ if (!seenFact.has(factId)) {
+ seenFact.add(factId);
+ nodes.push({
+ id: factId,
+ kind: 'fact',
+ label: (r.title ??
+ (r.kind === 'pin' ? r.note : null) ??
+ r.content_hash.slice(0, 8)) as string,
+ factKind: r.kind,
+ author: r.author,
+ branch,
+ observation_id: r.observation_id,
+ epoch: recordEpoch(r),
+ weight: r.kind === 'graduated' ? GRADUATED_WEIGHT : PIN_WEIGHT,
+ });
+ edges.push({ source: factId, target: `branch:${branch}`, kind: 'lives_on' });
+ if (r.author && r.author.trim() !== '') {
+ const cid = `contributor:${r.author}`;
+ edges.push({ source: cid, target: factId, kind: 'authored' });
+ contributorScore.set(
+ r.author,
+ (contributorScore.get(r.author) ?? 0) +
+ (r.kind === 'graduated' ? GRADUATED_WEIGHT : PIN_WEIGHT),
+ );
+ }
+ }
+ }
+
+ const branchNames = new Set([...branchFacts.keys(), ...extraBranches]);
+ for (const branch of [...branchNames].sort((a, b) => a.localeCompare(b))) {
+ nodes.push({
+ id: `branch:${branch}`,
+ kind: 'branch',
+ label: branch,
+ branch,
+ weight: branchFacts.get(branch) ?? 0,
+ });
+ edges.push({ source: `branch:${branch}`, target: 'root', kind: 'graduates_into' });
+ }
+
+ for (const [author, score] of [...contributorScore.entries()].sort((a, b) =>
+ a[0].localeCompare(b[0]),
+ )) {
+ nodes.push({
+ id: `contributor:${author}`,
+ kind: 'contributor',
+ label: author,
+ author,
+ weight: score,
+ });
+ }
+
+ return { nodes, edges };
+}
+
+export interface BranchAuthors {
+ branch: string;
+ authors: Array<{ name: string; commits: number }>;
+}
+
+/**
+ * Solo/unshared fallback: derive contributors from git commit authors per
+ * branch when nothing has been shared yet. value_score is the commit count so
+ * the leaderboard still ranks. These rows carry `commits` and zero facts.
+ */
+export function buildGitContributors(perBranch: BranchAuthors[], now: number): ContributorStat[] {
+ const byAuthor = new Map }>();
+ for (const { branch, authors } of perBranch) {
+ for (const { name, commits } of authors) {
+ let acc = byAuthor.get(name);
+ if (!acc) {
+ acc = { commits: 0, branches: new Set() };
+ byAuthor.set(name, acc);
+ }
+ acc.commits += commits;
+ acc.branches.add(branch);
+ }
+ }
+ const contributors: ContributorStat[] = [...byAuthor.entries()].map(([author, a]) => ({
+ author,
+ attributed: true,
+ pins: 0,
+ graduated: 0,
+ facts_total: 0,
+ commits: a.commits,
+ branches: [...a.branches].sort((x, y) => x.localeCompare(y)),
+ branches_touched: a.branches.size,
+ first_activity_epoch: now,
+ last_activity_epoch: now,
+ streak_days: 0,
+ value_score: a.commits,
+ breakdown: { pin: 0, graduated: 0 },
+ badges: [],
+ }));
+ contributors.sort((a, b) => b.value_score - a.value_score || a.author.localeCompare(b.author));
+ if (contributors[0]) contributors[0].badges.push('most_rooted');
+ return contributors;
+}
+
+/** Fallback graph: root + branches + contributors wired by `committed` edges. */
+export function buildGitGraph(
+ perBranch: BranchAuthors[],
+ project: string,
+): {
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+} {
+ const nodes: GraphNode[] = [{ id: 'root', kind: 'root', label: project }];
+ const edges: GraphEdge[] = [];
+ const contributorCommits = new Map();
+
+ for (const { branch, authors } of perBranch) {
+ const total = authors.reduce((s, a) => s + a.commits, 0);
+ nodes.push({ id: `branch:${branch}`, kind: 'branch', label: branch, branch, weight: total });
+ edges.push({ source: `branch:${branch}`, target: 'root', kind: 'graduates_into' });
+ for (const { name, commits } of authors) {
+ edges.push({ source: `contributor:${name}`, target: `branch:${branch}`, kind: 'committed' });
+ contributorCommits.set(name, (contributorCommits.get(name) ?? 0) + commits);
+ }
+ }
+ for (const [author, commits] of [...contributorCommits.entries()].sort((a, b) =>
+ a[0].localeCompare(b[0]),
+ )) {
+ nodes.push({
+ id: `contributor:${author}`,
+ kind: 'contributor',
+ label: author,
+ author,
+ weight: commits,
+ });
+ }
+ return { nodes, edges };
+}
diff --git a/test/sync-read.test.ts b/test/sync-read.test.ts
new file mode 100644
index 0000000..41d40c9
--- /dev/null
+++ b/test/sync-read.test.ts
@@ -0,0 +1,101 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, test } from 'vitest';
+
+import {
+ graduatedContentHash,
+ pinContentHash,
+ serializeSyncRecord,
+ SYNC_SCHEMA_VERSION,
+ type GraduatedRecord,
+ type PinRecord,
+} from '../src/sync/format.js';
+import { branchFilePath, graduatedFilePath } from '../src/sync/layout.js';
+import { readSyncDir, readSyncRecords } from '../src/sync/read.js';
+
+function pin(over: Partial = {}): PinRecord {
+ const base = {
+ project: 'p',
+ branch: 'feature/x',
+ observation_id: 1 as number | null,
+ note: null as string | null,
+ title: 'decision' as string | null,
+ body: 'body' as string | null,
+ ...over,
+ };
+ return {
+ schema: SYNC_SCHEMA_VERSION,
+ kind: 'pin',
+ content_hash: pinContentHash(base),
+ author: 'alice',
+ tagged_at_epoch: 100,
+ ...base,
+ } as PinRecord;
+}
+
+function grad(over: Partial = {}): GraduatedRecord {
+ const base = {
+ project: 'p',
+ observation_id: 9,
+ graduated_from_branch: 'feature/x',
+ title: 'rooted fact' as string | null,
+ body: 'body' as string | null,
+ ...over,
+ };
+ return {
+ schema: SYNC_SCHEMA_VERSION,
+ kind: 'graduated',
+ content_hash: graduatedContentHash(base),
+ author: 'bob',
+ graduated_at_epoch: 200,
+ ...base,
+ } as GraduatedRecord;
+}
+
+describe('readSyncRecords', () => {
+ let tmp: string;
+ let dir: string;
+
+ beforeEach(() => {
+ tmp = mkdtempSync(join(tmpdir(), 'tre-read-'));
+ dir = join(tmp, '.tre-mem');
+ });
+
+ afterEach(() => {
+ rmSync(tmp, { recursive: true, force: true });
+ });
+
+ test('returns empty for a missing directory', () => {
+ expect(readSyncRecords(join(tmp, 'nope'))).toEqual([]);
+ });
+
+ test('reads pins from branches/*.jsonl and facts from graduated.jsonl', () => {
+ mkdirSync(join(dir, 'branches'), { recursive: true });
+ writeFileSync(
+ branchFilePath(dir, 'feature/x'),
+ `${serializeSyncRecord(pin({ observation_id: 1 }))}\n${serializeSyncRecord(
+ pin({ observation_id: 2, title: 'second' }),
+ )}\n`,
+ );
+ writeFileSync(graduatedFilePath(dir), `${serializeSyncRecord(grad())}\n`);
+
+ const { pins, graduated } = readSyncDir(dir);
+ expect(pins).toHaveLength(2);
+ expect(graduated).toHaveLength(1);
+ expect(pins[0]?.author).toBe('alice');
+ expect(graduated[0]?.author).toBe('bob');
+ });
+
+ test('skips malformed and blank lines without throwing', () => {
+ mkdirSync(join(dir, 'branches'), { recursive: true });
+ writeFileSync(
+ branchFilePath(dir, 'feature/x'),
+ `${serializeSyncRecord(pin())}\n\nnot json at all\n{"schema":999,"kind":"pin"}\n`,
+ );
+
+ const records = readSyncRecords(dir);
+ expect(records).toHaveLength(1);
+ expect(records[0]?.kind).toBe('pin');
+ });
+});
diff --git a/test/web-grove.test.ts b/test/web-grove.test.ts
new file mode 100644
index 0000000..071889b
--- /dev/null
+++ b/test/web-grove.test.ts
@@ -0,0 +1,201 @@
+import { type Server } from 'node:http';
+import { type AddressInfo } from 'node:net';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { migrate } from '../src/store/migrate.js';
+import { TreMemRepo } from '../src/store/repo.js';
+import {
+ graduatedContentHash,
+ pinContentHash,
+ serializeSyncRecord,
+ SYNC_SCHEMA_VERSION,
+ type GraduatedRecord,
+ type PinRecord,
+} from '../src/sync/format.js';
+import { branchFilePath, graduatedFilePath } from '../src/sync/layout.js';
+import {
+ aggregateContributors,
+ buildGraph,
+ GRADUATED_WEIGHT,
+ PIN_WEIGHT,
+ UNATTRIBUTED,
+} from '../src/web/grove.js';
+import { createWebServer } from '../src/web/server.js';
+import { SseHub } from '../src/web/sse.js';
+import { type WebDeps } from '../src/web/types.js';
+
+const PROJECT = 'demo';
+const DAY = 24 * 60 * 60;
+
+function pin(over: Partial = {}): PinRecord {
+ const base = {
+ project: PROJECT,
+ branch: 'feature/x',
+ observation_id: 1 as number | null,
+ note: null as string | null,
+ title: 'a decision' as string | null,
+ body: 'body' as string | null,
+ ...over,
+ };
+ return {
+ schema: SYNC_SCHEMA_VERSION,
+ kind: 'pin',
+ content_hash: pinContentHash(base),
+ author: 'alice',
+ tagged_at_epoch: 100,
+ ...base,
+ } as PinRecord;
+}
+
+function grad(over: Partial = {}): GraduatedRecord {
+ const base = {
+ project: PROJECT,
+ observation_id: 9,
+ graduated_from_branch: 'feature/x',
+ title: 'rooted' as string | null,
+ body: 'body' as string | null,
+ ...over,
+ };
+ return {
+ schema: SYNC_SCHEMA_VERSION,
+ kind: 'graduated',
+ content_hash: graduatedContentHash(base),
+ author: 'bob',
+ graduated_at_epoch: 200,
+ ...base,
+ } as GraduatedRecord;
+}
+
+describe('aggregateContributors (pure)', () => {
+ it('weights graduated facts 3x and ranks by value_score', () => {
+ const now = 10 * DAY;
+ const records = [
+ pin({ author: 'alice', observation_id: 1, tagged_at_epoch: now }),
+ pin({ author: 'alice', observation_id: 2, title: 'b', tagged_at_epoch: now }),
+ grad({
+ author: 'bob',
+ observation_id: 3,
+ tagged_at_epoch: undefined,
+ graduated_at_epoch: now,
+ }),
+ ];
+ const { contributors } = aggregateContributors(records, PROJECT, now);
+ const alice = contributors.find((c) => c.author === 'alice')!;
+ const bob = contributors.find((c) => c.author === 'bob')!;
+ expect(alice.value_score).toBe(2 * PIN_WEIGHT);
+ expect(bob.value_score).toBe(1 * GRADUATED_WEIGHT);
+ // bob (3) outranks alice (2) despite fewer facts → graduated weight wins.
+ expect(contributors[0]?.author).toBe('bob');
+ });
+
+ it('buckets null-author records under UNATTRIBUTED', () => {
+ const { contributors, attributed_total, unattributed_total } = aggregateContributors(
+ [pin({ author: null, observation_id: 5 })],
+ PROJECT,
+ 1000,
+ );
+ expect(attributed_total).toBe(0);
+ expect(unattributed_total).toBe(1);
+ expect(contributors[0]?.author).toBe(UNATTRIBUTED);
+ expect(contributors[0]?.attributed).toBe(false);
+ });
+
+ it('awards most_rooted to the top graduator and first_sprout to the earliest', () => {
+ const now = 30 * DAY;
+ const records = [
+ pin({ author: 'alice', observation_id: 1, tagged_at_epoch: 5 * DAY }),
+ grad({ author: 'bob', observation_id: 2, graduated_at_epoch: 20 * DAY }),
+ grad({ author: 'bob', observation_id: 3, title: 'g2', graduated_at_epoch: 21 * DAY }),
+ ];
+ const { contributors } = aggregateContributors(records, PROJECT, now);
+ const bob = contributors.find((c) => c.author === 'bob')!;
+ const alice = contributors.find((c) => c.author === 'alice')!;
+ expect(bob.badges).toContain('most_rooted');
+ expect(alice.badges).toContain('first_sprout');
+ });
+});
+
+describe('buildGraph (pure)', () => {
+ it('emits root + branch + contributor + fact nodes with authored/lives_on/graduates_into edges', () => {
+ const { nodes, edges } = buildGraph(
+ [pin({ author: 'alice' }), grad({ author: 'bob' })],
+ ['main'],
+ PROJECT,
+ );
+ const kinds = new Set(nodes.map((n) => n.kind));
+ expect(kinds).toEqual(new Set(['root', 'branch', 'contributor', 'fact']));
+ expect(nodes.some((n) => n.id === 'branch:main')).toBe(true); // extra branch with no facts
+ expect(edges.some((e) => e.kind === 'authored')).toBe(true);
+ expect(edges.some((e) => e.kind === 'lives_on')).toBe(true);
+ expect(edges.some((e) => e.kind === 'graduates_into')).toBe(true);
+ });
+});
+
+describe('web API — grove endpoints', () => {
+ let tmp: string;
+ let repo: TreMemRepo;
+ let server: Server;
+ let base: string;
+
+ beforeAll(async () => {
+ tmp = mkdtempSync(join(tmpdir(), 'tre-grove-'));
+ const dir = join(tmp, '.tre-mem');
+ mkdirSync(join(dir, 'branches'), { recursive: true });
+ writeFileSync(
+ branchFilePath(dir, 'feature/x'),
+ `${serializeSyncRecord(pin({ author: 'alice', observation_id: 1 }))}\n`,
+ );
+ writeFileSync(graduatedFilePath(dir), `${serializeSyncRecord(grad({ author: 'bob' }))}\n`);
+
+ const dbPath = join(tmp, 'tre-mem.db');
+ migrate(dbPath);
+ repo = new TreMemRepo({ dbPath });
+
+ const deps: WebDeps = {
+ repo,
+ adapter: null,
+ cwd: tmp,
+ project: PROJECT,
+ staticDir: join(tmp, 'no-static'),
+ version: '9.9.9',
+ now: () => 1000,
+ sse: new SseHub(),
+ };
+ server = createWebServer(deps);
+ await new Promise((res) => server.listen(0, '127.0.0.1', () => res()));
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ });
+
+ afterAll(async () => {
+ await new Promise((res) => server.close(() => res()));
+ repo.close();
+ rmSync(tmp, { recursive: true, force: true });
+ });
+
+ type Obj = Record;
+ async function get(path: string): Promise<{ status: number; body: Obj }> {
+ const r = await fetch(`${base}${path}`);
+ return { status: r.status, body: (await r.json()) as Obj };
+ }
+
+ it('GET /api/contributors aggregates shared JSONL authors', async () => {
+ const { status, body } = await get('/api/contributors');
+ expect(status).toBe(200);
+ expect(body.source).toBe('shared');
+ expect(body.attributed_total).toBe(2);
+ const authors = (body.contributors as Obj[]).map((c) => c.author);
+ expect(authors).toContain('alice');
+ expect(authors).toContain('bob');
+ });
+
+ it('GET /api/graph returns nodes + edges from the shared layer', async () => {
+ const { status, body } = await get('/api/graph');
+ expect(status).toBe(200);
+ expect(body.source).toBe('shared');
+ expect((body.nodes as Obj[]).some((n) => n.kind === 'contributor')).toBe(true);
+ expect((body.edges as Obj[]).length).toBeGreaterThan(0);
+ });
+});
diff --git a/web/app.tsx b/web/app.tsx
index c82c7c7..9296539 100644
--- a/web/app.tsx
+++ b/web/app.tsx
@@ -1,12 +1,14 @@
import { useCallback, useEffect, useState } from 'react';
+import { useI18n } from './i18n.js';
import { clsx, useApi, useSse, type BranchesResponse, type Health } from './lib.js';
import { BranchDetail } from './views/BranchDetail.js';
+import { Grove } from './views/Grove.js';
import { Overview } from './views/Overview.js';
import { Search } from './views/Search.js';
import { TeamMemory } from './views/TeamMemory.js';
-type Tab = 'overview' | 'team' | 'search';
+type Tab = 'overview' | 'grove' | 'team' | 'search';
interface ProjectsResponse {
current: string;
@@ -14,6 +16,7 @@ interface ProjectsResponse {
}
export function App() {
+ const { t } = useI18n();
const health = useApi('/api/health', 'init');
const projects = useApi('/api/projects', 'init');
@@ -55,7 +58,7 @@ export function App() {