diff --git a/CHANGELOG.md b/CHANGELOG.md index 333eb38..0240a64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ 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.11.0] — 2026-06-07 + +**Facts you can take back.** Memory was append-only — a graduated or pinned fact +could never be removed, even after PR feedback or QC proved it wrong. Now the +assistant (or you) can forget a fact, and the removal **propagates to the whole +team** through git. Plus a smoother first run and a SessionStart banner that shows +once, below claude-mem. + +### Added + +- **`unpin_fact` / `ungraduate_fact` MCP tools** (now **9** tools total) — the + inverse of `pin_fact` / `graduate_fact`. Removing a fact that was already shared + writes a **tombstone** to `.tre-mem/` so every teammate's clone drops it on the + next import. Correcting a fact = `ungraduate_fact` then `graduate_fact` the fixed + observation. Same model as before: removal is local + pending until + `export_memory` (or `tre share`) publishes it. +- **`tre unpin` / `tre ungraduate` CLI** — terminal parity with the MCP tools. +- **Tombstone sync record** — a new append-only `.tre-mem/` line kind that carries + the `content_hash` of the removed fact. Kept at sync schema v1: pre-v0.11 clients + skip it gracefully (the fact simply lingers for them). Import applies tombstones + in a two-pass per file so a removal can never be resurrected by an earlier line. +- **`tre init --all`** — guided first run that wires every installed AI tool and + backfills past observations in one step, then prints clear next steps. +- **`tre doctor --fix-hooks`** — detects a tre-mem SessionStart hook registered in + more than one settings file (the cause of duplicate banners) and collapses it to + one. + +### Changed + +- **`tre status`** — regrouped into scannable **Identity / Memory / Tools / + claude-mem** sections with a headline `full` / `shared-only` mode. +- **SessionStart banner** — defers briefly (env-tunable `TRE_MEM_HOOK_DELAY_MS`, + default 250 ms when claude-mem is present) so it renders **below** claude-mem + instead of racing it. Combined with `--fix-hooks`, the banner shows exactly once. + ## [0.10.0] — 2026-06-07 **The agent shares for you, and memory crosses your clones.** Two workflow gaps diff --git a/CLAUDE.md b/CLAUDE.md index 65750e0..d172b7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — Codebase Guide for Claude Code -> **Always read `PLAN.md` first.** It is the roadmap & status index — current version, what shipped, and the phase-SSOT chain. This file gives Claude orientation; `PLAN.md` gives the work. Current: **v0.10.0**. Phase SSOTs: `docs/plan/PLAN-PHASE2.md` (v0.2–0.4 team share), `docs/plan/PLAN-PHASE3.md` (v0.5 web dashboard), `docs/plan/PLAN-PHASE4.md` (v0.6 cross-tool), `docs/plan/PLAN-PHASE5.md` (v0.7 "share, made obvious"), `docs/plan/PLAN-PHASE6.md` (v0.8–0.9 bamboo identity + the Grove), `docs/plan/PLAN-PHASE7.md` (v0.10 agent-driven export + cross-clone memory). +> **Always read `PLAN.md` first.** It is the roadmap & status index — current version, what shipped, and the phase-SSOT chain. This file gives Claude orientation; `PLAN.md` gives the work. Current: **v0.11.0**. Phase SSOTs: `docs/plan/PLAN-PHASE2.md` (v0.2–0.4 team share), `docs/plan/PLAN-PHASE3.md` (v0.5 web dashboard), `docs/plan/PLAN-PHASE4.md` (v0.6 cross-tool), `docs/plan/PLAN-PHASE5.md` (v0.7 "share, made obvious"), `docs/plan/PLAN-PHASE6.md` (v0.8–0.9 bamboo identity + the Grove), `docs/plan/PLAN-PHASE7.md` (v0.10 agent-driven export + cross-clone memory), `docs/plan/PLAN-PHASE8.md` (v0.11 forget/correct facts via tombstones + onboarding/banner polish). ## What this project is diff --git a/PLAN.md b/PLAN.md index 4d4baf2..1b28b8f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -14,20 +14,21 @@ ## Current status -**Shipped: v0.10.0** — agent-driven export + cross-clone memory. Covered by -**366 tests**, green on Node 20 + 22. Releases are tracked in -[CHANGELOG.md](./CHANGELOG.md). - -| Version | Theme | Phase SSOT | -| ---------- | ------------------------------------------------------------------------- | -------------------------------------------- | -| **v0.10** | Agent-driven `export_memory` + cross-clone memory union (by git remote) | [PLAN-PHASE7.md](./docs/plan/PLAN-PHASE7.md) | -| **v0.9** | "The Grove" — contributor graph + leaderboard + full Vietnamese i18n | [PLAN-PHASE6.md](./docs/plan/PLAN-PHASE6.md) | -| **v0.8** | Bamboo-green design identity (web + terminal), `docs/BRAND.md` SSOT | [PLAN-PHASE6.md](./docs/plan/PLAN-PHASE6.md) | -| **v0.7** | "Share, made obvious" — one-command `tre share`, local graduate-on-merge | [PLAN-PHASE5.md](./docs/plan/PLAN-PHASE5.md) | -| **v0.6** | Cross-tool — Codex / Gemini / Cursor / Antigravity via MCP | [PLAN-PHASE4.md](./docs/plan/PLAN-PHASE4.md) | -| **v0.5** | Local team dashboard (`tre web`) — branch graph + team memory, live (SSE) | [PLAN-PHASE3.md](./docs/plan/PLAN-PHASE3.md) | -| **v0.2–4** | Git-native team share — export/import, redaction, branch graduation | [PLAN-PHASE2.md](./docs/plan/PLAN-PHASE2.md) | -| **v0.1** | Branch-aware retrieval (3-signal rerank) + MCP server | Phase 1 (this file, § Phase 1 recap) | +**Shipped: v0.11.0** — forget/correct facts (propagating tombstones) + onboarding +& banner polish. Covered by **400+ tests**, green on Node 20 + 22. Releases are +tracked in [CHANGELOG.md](./CHANGELOG.md). + +| Version | Theme | Phase SSOT | +| ---------- | -------------------------------------------------------------------------- | -------------------------------------------- | +| **v0.11** | Forget/correct facts via propagating tombstones + onboarding/banner polish | [PLAN-PHASE8.md](./docs/plan/PLAN-PHASE8.md) | +| **v0.10** | Agent-driven `export_memory` + cross-clone memory union (by git remote) | [PLAN-PHASE7.md](./docs/plan/PLAN-PHASE7.md) | +| **v0.9** | "The Grove" — contributor graph + leaderboard + full Vietnamese i18n | [PLAN-PHASE6.md](./docs/plan/PLAN-PHASE6.md) | +| **v0.8** | Bamboo-green design identity (web + terminal), `docs/BRAND.md` SSOT | [PLAN-PHASE6.md](./docs/plan/PLAN-PHASE6.md) | +| **v0.7** | "Share, made obvious" — one-command `tre share`, local graduate-on-merge | [PLAN-PHASE5.md](./docs/plan/PLAN-PHASE5.md) | +| **v0.6** | Cross-tool — Codex / Gemini / Cursor / Antigravity via MCP | [PLAN-PHASE4.md](./docs/plan/PLAN-PHASE4.md) | +| **v0.5** | Local team dashboard (`tre web`) — branch graph + team memory, live (SSE) | [PLAN-PHASE3.md](./docs/plan/PLAN-PHASE3.md) | +| **v0.2–4** | Git-native team share — export/import, redaction, branch graduation | [PLAN-PHASE2.md](./docs/plan/PLAN-PHASE2.md) | +| **v0.1** | Branch-aware retrieval (3-signal rerank) + MCP server | Phase 1 (this file, § Phase 1 recap) | --- diff --git a/README.md b/README.md index 6ceb7ab..90b13d4 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ sees Stripe webhook chatter alongside JWT context. tre-mem fixes that: - **History backfill** via `git reflog` so existing observations get a branch. - **3-signal rerank**: semantic (FTS5/BM25), branch locality, recency-in-branch, plus a `pin` boost for facts you want pinned to a branch. -- **MCP server** exposing 7 tools so Claude Code can call branch-aware - retrieval — and publish memory — directly. +- **MCP server** exposing 9 tools so Claude Code can call branch-aware + retrieval — and curate, publish, and retract memory — directly. On the tre-mem repo itself the rerank lifts precision@10 from **0.19** (raw FTS5 baseline) to **0.97**. See [BENCHMARK.md](./docs/BENCHMARK.md) for the harness. @@ -95,7 +95,7 @@ CLI directly: `claude mcp add -s user tre-mem -- node /abs/path/to/tre-mem/dist/ Verify inside Claude Code with `/mcp`. You should see: ``` -tre-mem · connected · 7 tools +tre-mem · connected · 9 tools ``` ## Register the SessionStart hook (optional but recommended) @@ -209,15 +209,17 @@ tre-mem search "stripe webhook" ## MCP tools -| Tool | Input | Output | -| --------------------- | ------------------------------------ | --------------------------------------------- | -| `get_branch_context` | `query`, `project?`, `branch?`, `k?` | Top-K observations, rerank breakdown included | -| `get_branch_timeline` | `branch`, `project?`, `limit?` | Chronological feed for a branch | -| `list_branches` | `project?` | Branches with tag counts | -| `pin_fact` | `observation_id`, `branch?`, `note?` | Pin a fact to a branch (boost = 1.0) | -| `graduate_fact` | `observation_id` | Promote a branch fact to project scope | -| `export_memory` | `branch?`, `all?`, `force?` | Write `.tre-mem/` + local commit (no push) | -| `get_share_status` | `project?` | Pending / shared / graduated counts | +| Tool | Input | Output | +| --------------------- | ------------------------------------ | ---------------------------------------------- | +| `get_branch_context` | `query`, `project?`, `branch?`, `k?` | Top-K observations, rerank breakdown included | +| `get_branch_timeline` | `branch`, `project?`, `limit?` | Chronological feed for a branch | +| `list_branches` | `project?` | Branches with tag counts | +| `pin_fact` | `observation_id`, `branch?`, `note?` | Pin a fact to a branch (boost = 1.0) | +| `graduate_fact` | `observation_id` | Promote a branch fact to project scope | +| `unpin_fact` | `observation_id`, `branch?` | Remove a pin (tombstones it if shared) | +| `ungraduate_fact` | `observation_id` | Remove a graduated fact (tombstones if shared) | +| `export_memory` | `branch?`, `all?`, `force?` | Write `.tre-mem/` + local commit (no push) | +| `get_share_status` | `project?` | Pending / shared / graduated counts | After the assistant pins or graduates a fact, it can call **`export_memory`** to publish it: this writes the `.tre-mem/` files and makes a **local git commit** — it @@ -225,6 +227,12 @@ never pushes, so you review and `git push` when ready (the tool returns the exac command). It's fail-closed on secrets: a blocked export reports the matched secret _categories_, never the values. +When a fact goes stale — a leader's PR feedback, a bug found in QC — the assistant +calls **`unpin_fact`** or **`ungraduate_fact`** to take it back. If the fact was +already shared, this writes a **tombstone** to `.tre-mem/`; on the next `git pull` +every teammate's clone drops it automatically. To _correct_ a fact, ungraduate the +old one and `graduate_fact` the fixed observation. + ## Cross-clone memory If you clone the same repo into several directories (e.g. `app`, `app-2`, `app-3`) diff --git a/README.vi.md b/README.vi.md index 0b0dd39..2281b01 100644 --- a/README.vi.md +++ b/README.vi.md @@ -54,7 +54,8 @@ Stripe webhook với JWT context. tre-mem xử lý điều đó: - **Backfill lịch sử** qua `git reflog` để observation cũ cũng có branch. - **Rerank 3-signal**: semantic (FTS5/BM25), branch locality, recency-trong-branch, cộng thêm pin boost cho fact muốn ghim vào branch. -- **MCP server** expose 7 tool để Claude Code gọi retrieval branch-aware trực tiếp. +- **MCP server** expose 9 tool để Claude Code gọi retrieval branch-aware, đồng thời + curate, công bố và thu hồi memory — trực tiếp. Trên chính repo tre-mem, rerank nâng precision@10 từ **0.19** (FTS5 baseline thuần) lên **0.97**. Xem [BENCHMARK.md](./docs/BENCHMARK.md) để biết chi tiết harness. @@ -93,7 +94,7 @@ claude mcp add -s user tre-mem -- tre mcp Kiểm tra trong Claude Code bằng `/mcp`. Sẽ thấy: ``` -tre-mem · connected · 7 tools +tre-mem · connected · 9 tools ``` ## Đăng ký SessionStart hook (tùy chọn nhưng nên có) @@ -205,15 +206,17 @@ tre-mem search "stripe webhook" ## MCP tools -| Tool | Input | Output | -| --------------------- | ------------------------------------ | -------------------------------------------- | -| `get_branch_context` | `query`, `project?`, `branch?`, `k?` | Top-K observations, kèm breakdown rerank | -| `get_branch_timeline` | `branch`, `project?`, `limit?` | Feed theo thời gian cho 1 branch | -| `list_branches` | `project?` | Các branch kèm số lượng tag | -| `pin_fact` | `observation_id`, `branch?`, `note?` | Ghim fact vào branch (boost = 1.0) | -| `graduate_fact` | `observation_id` | Promote fact từ branch lên scope project | -| `export_memory` | `branch?`, `all?`, `force?` | Ghi `.tre-mem/` + commit cục bộ (không push) | -| `get_share_status` | `project?` | Số lượng pending / shared / graduated | +| Tool | Input | Output | +| --------------------- | ------------------------------------ | ---------------------------------------------- | +| `get_branch_context` | `query`, `project?`, `branch?`, `k?` | Top-K observations, kèm breakdown rerank | +| `get_branch_timeline` | `branch`, `project?`, `limit?` | Feed theo thời gian cho 1 branch | +| `list_branches` | `project?` | Các branch kèm số lượng tag | +| `pin_fact` | `observation_id`, `branch?`, `note?` | Ghim fact vào branch (boost = 1.0) | +| `graduate_fact` | `observation_id` | Promote fact từ branch lên scope project | +| `unpin_fact` | `observation_id`, `branch?` | Gỡ pin (ghi tombstone nếu đã chia sẻ) | +| `ungraduate_fact` | `observation_id` | Gỡ fact đã graduate (tombstone nếu đã chia sẻ) | +| `export_memory` | `branch?`, `all?`, `force?` | Ghi `.tre-mem/` + commit cục bộ (không push) | +| `get_share_status` | `project?` | Số lượng pending / shared / graduated | Sau khi assistant pin hoặc graduate một fact, nó có thể gọi **`export_memory`** để công bố: lệnh này ghi các file `.tre-mem/` và tạo một **commit git cục bộ** — nó @@ -221,6 +224,12 @@ công bố: lệnh này ghi các file `.tre-mem/` và tạo một **commit git c đúng câu lệnh cần chạy). Lệnh fail-closed với secret: một lần export bị chặn chỉ báo _loại_ secret khớp, không bao giờ lộ giá trị. +Khi một fact lỗi thời — feedback PR của leader, bug phát hiện ở QC — assistant gọi +**`unpin_fact`** hoặc **`ungraduate_fact`** để thu hồi. Nếu fact đã được chia sẻ, +lệnh này ghi một **tombstone** vào `.tre-mem/`; ở lần `git pull` kế tiếp mọi clone +của đồng đội tự động bỏ nó. Để _sửa_ một fact, ungraduate cái cũ rồi `graduate_fact` +observation đã sửa. + ## Memory xuyên nhiều clone Nếu bạn clone cùng một repo vào nhiều thư mục (ví dụ `app`, `app-2`, `app-3`) để diff --git a/docs/CROSS-TOOL.md b/docs/CROSS-TOOL.md index 28ae964..5110cec 100644 --- a/docs/CROSS-TOOL.md +++ b/docs/CROSS-TOOL.md @@ -80,7 +80,7 @@ tre mcp # start by hand; without claude-mem it prints "shared-memory In the tool, list MCP tools — you should see `get_branch_context`, `get_branch_timeline`, `list_branches`, `pin_fact`, `graduate_fact`, -`export_memory`, `get_share_status`. Ask: +`unpin_fact`, `ungraduate_fact`, `export_memory`, `get_share_status`. Ask: _"what has the team pinned on this branch?"_ ## Notes diff --git a/docs/DEMO.md b/docs/DEMO.md index 39d7bc4..1f25a2c 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -148,7 +148,7 @@ Claude Code session in the same repo. **Expected**: Claude calls the `tre-mem.get_branch_context` MCP tool and answers in terms of **BMOtpTextView**, not AccountManager. (Check `/mcp` shows -`tre-mem · connected · 7 tools` before recording.) +`tre-mem · connected · 9 tools` before recording.) Then `git checkout feature/test_tre_mem` in the terminal, ask the same question again, and watch Claude switch to **AccountManager**. diff --git a/docs/SYNC-FORMAT.md b/docs/SYNC-FORMAT.md index 9f58a61..51fb396 100644 --- a/docs/SYNC-FORMAT.md +++ b/docs/SYNC-FORMAT.md @@ -36,12 +36,12 @@ a fixed order for stable, reviewable diffs. ### Common fields -| Field | Type | Notes | -| -------------- | ------------------------ | ------------------------------------------------------------------------------------ | -| `schema` | number | Format version. Currently `1`. A reader MUST reject versions it does not understand. | -| `kind` | `"pin"` \| `"graduated"` | Discriminates the row shape. | -| `content_hash` | string | SHA-256 (64 hex chars) over the row's _semantic content only_. The dedupe key. | -| `author` | string \| null | Who exported the row (best-effort, from git config). Excluded from the hash. | +| Field | Type | Notes | +| -------------- | ----------------------------------------- | ------------------------------------------------------------------------------------ | +| `schema` | number | Format version. Currently `1`. A reader MUST reject versions it does not understand. | +| `kind` | `"pin"` \| `"graduated"` \| `"tombstone"` | Discriminates the row shape. | +| `content_hash` | string | SHA-256 (64 hex chars) over the row's _semantic content only_. The dedupe key. | +| `author` | string \| null | Who exported the row (best-effort, from git config). Excluded from the hash. | ### `pin` record @@ -93,6 +93,44 @@ a fixed order for stable, reviewable diffs. | `graduated_from_branch` | string | Branch the fact was promoted from. | | `graduated_at_epoch` | number | Graduation time (seconds). Excluded from the hash. | +### `tombstone` record (v0.11+) + +A removal marker. When a pin or graduated fact is forgotten (`unpin_fact` / +`ungraduate_fact`, or `tre unpin` / `tre ungraduate`) after it was already shared, +a tombstone is appended to the **same file** as the target — pin tombstones to +`branches/.jsonl`, graduated tombstones to `graduated.jsonl`. It carries the +`content_hash` of the fact to drop. + +```json +{ + "schema": 1, + "kind": "tombstone", + "content_hash": "", + "target_kind": "graduated", + "project": "tre-mem", + "branch": null, + "observation_id": 99, + "author": "bob", + "tombstoned_at_epoch": 1780001000 +} +``` + +| Field | Type | | +| --------------------- | ------------------------ | --------------------------------------------- | +| `content_hash` | string | Hash of the pin/graduated fact being removed. | +| `target_kind` | `"pin"` \| `"graduated"` | Which table the import should delete from. | +| `branch` | string \| null | Set for pin tombstones; `null` for graduated. | +| `tombstoned_at_epoch` | number | When the fact was forgotten (seconds). | + +On import, each file is processed in **two passes**: collect all tombstoned +`content_hash`es first, then insert only non-tombstoned facts and delete any rows a +tombstone targets. This makes removal order-independent within a file and idempotent +across re-imports — a forgotten fact can never be resurrected by an earlier line. + +**Back-compat:** tombstones stay at `schema: 1`. Pre-v0.11 clients hit "unknown +kind" and skip the line (counting it as a parse error), so the fact simply lingers +for them; it disappears the moment they upgrade. No `SYNC_SCHEMA_VERSION` bump. + ## Content hashing (dedupe semantics) `content_hash` is SHA-256 of the row's identity fields joined by the ASCII Unit Separator @@ -115,6 +153,9 @@ left in the file (the file stays append-only; import resolves at read time). ## Versioning -The `schema` field is per-row, not per-file, so a future v2 can introduce new record shapes -incrementally. Readers reject unknown `schema` values loudly rather than silently dropping data. Any -format change ships with a bump to `SYNC_SCHEMA_VERSION` and a note in `docs/MIGRATION-v1-v2.md`. +The `schema` field is per-row, not per-file, so new record shapes can be introduced incrementally. +Readers reject unknown `schema` values loudly rather than silently dropping data, but **unknown +`kind` values at a known schema are skipped gracefully** (counted as a parse error, never fatal). +That second rule is what lets the v0.11 `tombstone` kind ship _without_ a `SYNC_SCHEMA_VERSION` bump: +old clients skip it, new clients honor it. A change that alters an existing row's shape — rather than +adding a new kind — would still bump `SYNC_SCHEMA_VERSION`. diff --git a/docs/plan/PLAN-PHASE8.md b/docs/plan/PLAN-PHASE8.md new file mode 100644 index 0000000..525783c --- /dev/null +++ b/docs/plan/PLAN-PHASE8.md @@ -0,0 +1,89 @@ +# PLAN — Phase 8 (v0.11.x): Forget/correct facts + onboarding & banner polish + +> Phase SSOT, chained from [`PLAN.md`](../../PLAN.md). Status: **done (v0.11.0)**. + +## Why + +Three gaps surfaced from daily use: + +1. **Memory was append-only — facts could never be removed or corrected.** A + graduated/pinned fact routinely goes wrong: a leader's PR feedback, a bug found + in QC, a changed approach. There was **zero** delete capability anywhere. And a + shared fact lives in `.tre-mem/*.jsonl` — append-only, content-hash-deduped, + git-committed, auto-imported — so deleting the local row alone leaves every + teammate's clone holding the stale fact forever. +2. **Onboarding was terse; `tre status` was a wall of text.** +3. **The SessionStart banner duplicated and rendered above claude-mem.** + +User decisions: forget = **local delete + propagating tombstone**; API = **granular +`unpin_fact` / `ungraduate_fact`** (mirror `pin_fact` / `graduate_fact`); banner = +**dedup + sit below claude-mem**; all three ship together as **v0.11.0**. + +## A — Forget / correct facts (tombstone propagation) + +"Update" is not a separate method: correcting a fact = `ungraduate_fact` (tombstone +the old content_hash) then `graduate_fact` the fixed observation (new content → new +hash → new line). + +- **Tombstone sync record** (`src/sync/format.ts`) — new `kind: 'tombstone'` + carrying the removed fact's `content_hash` + `target_kind`. Stays at + `SYNC_SCHEMA_VERSION = 1`; pre-v0.11 clients skip it via the existing unknown-kind + `catch` (graceful, the fact just lingers for them until they upgrade). +- **Store deletes** (`src/store/repo.ts`) — `deletePinById`, + `deletePinsByObservation`, `deletePinsByContentHash`, + `deleteGraduatedByObservation`, `deleteGraduatedByContentHash`. +- **Two-pass import** (`src/sync/import.ts`) — pass 1 collects tombstoned hashes; + pass 2 inserts only non-tombstoned facts and deletes any the tombstones target. + Order-independent within a file, idempotent across re-imports (a removal can never + be resurrected by an earlier line). `ImportResult` gains `tombstoned`. +- **Forget flow** (`src/sync/forget.ts`) — `forgetPin` / `forgetGraduated` write the + tombstone to `.tre-mem/` **at call time** (before deleting the row — once the row + is gone there is nothing left for `export_memory` to emit), then delete locally. + Unshared facts (no `content_hash`) are deleted with no tombstone. Tombstone append + is idempotent (dedup by `kind:'tombstone'` + hash). `readLiveSyncRecords` + (`src/sync/read.ts`) applies tombstones so the web layer never surfaces a forgotten + fact. +- **MCP tools** (`src/mcp/tools.ts`, 7 → **9**) — `unpin_fact` (`observation_id`, + `branch?`, `project?`, `cwd?`) and `ungraduate_fact` (`observation_id`, `project?`, + `cwd?`). Both use the write key (`basename(cwd)`). Hints point at `export_memory`. +- **CLI** (`src/cli.ts`) — `tre unpin ` / `tre ungraduate `. + +## B — Onboarding & `tre status` + +- **`tre init [--all] [--verbose]`** (`src/cli.ts`) — guided first run: quiet + migration, claude-mem mode line, and a numbered "Next steps" block. `--all` chains + `setupAll(cwd)` + `backfill` in one step. Reuses `diagnoseClaudeMem`, + `setupAll`, `backfill`. +- **`tre status`** — regrouped into **Identity / Memory / Tools / claude-mem** + sections with a headline `full` / `shared-only` mode. Same data, no new sources; + freshness now via `probeClaudeMemIngest`. + +## C — SessionStart banner: dedup + below claude-mem + +Root cause of duplication: the tre-mem hook was registered in **two** settings files +(project `.claude/settings.json` and global `~/.claude/settings.json`) → fired twice. + +- **Detection + fix** (`src/setup.ts`) — `scanSessionStartHooks(cwd, files?)` counts + tre-mem SessionStart hooks per file (predicate: command matches `hook +session-start`); `dedupeSessionStartHooks(cwd, keep, files?)` collapses to one + (keeps the global copy by default). Both take an injectable file list for tests. +- **`tre doctor [--fix-hooks]`** — reports the duplicate (naming each file) and, with + the flag, collapses it. +- **Ordering** (`src/cli.ts` `runSessionStartHookCli`) — defers the banner by + `sessionHookDelayMs(format)`: env `TRE_MEM_HOOK_DELAY_MS`, default **250 ms** when + claude-mem is present on Claude Code, else 0. Best-effort so tre-mem renders below + claude-mem (which renders in completion order); fully tunable. + +## Compatibility + +- On-disk format + `SYNC_SCHEMA_VERSION` **unchanged** (tombstone is a new kind, not + a new schema). Old clients skip tombstones; new clients honor them. +- No claude-mem schema changes (read-only as always). + +## Verification + +- Pre-push gate green (format/lint/typecheck/test/build), Node 20 + 22. +- Live: `graduate` → `share` → `ungraduate` (tombstone appended + local row gone) → + second clone `import` drops the fact. MCP: `graduate_fact` then `ungraduate_fact`. +- Onboarding: fresh `~/.tre-mem` → `tre init --all`; sectioned `tre status`. +- Banner: `tre doctor` flags the duplicate; `--fix-hooks` collapses to one. diff --git a/package.json b/package.json index 26f36ea..8bde409 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tre-mem", - "version": "0.10.0", + "version": "0.11.0", "description": "Branch-aware shared memory layer for AI coding tools. Sidecar on top of claude-mem.", "keywords": [ "claude-code", diff --git a/src/cli.ts b/src/cli.ts index cb620ce..c4866e5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,18 +26,26 @@ import { type UserPromptSubmitInput, runUserPromptSubmitHook } from './hooks/use import { log, logError, logFilePath } from './log/logger.js'; import { readLogTail } from './log/read.js'; import { runMcpServer } from './mcp/server.js'; -import { detectTools, installPostMergeHook, setupAll, setupTool } from './setup.js'; +import { + detectTools, + dedupeSessionStartHooks, + installPostMergeHook, + scanSessionStartHooks, + setupAll, + setupTool, +} from './setup.js'; import { searchBranchContext, type SearchHit } from './retrieval/search.js'; import { migrate } from './store/migrate.js'; import { SYNC_DIR_NAME, ensureSyncScaffold } from './sync/layout.js'; import { exportSync, type SnapshotProvider } from './sync/export.js'; +import { forgetGraduated, forgetPin } from './sync/forget.js'; import { graduateBranch } from './sync/graduate.js'; import { importDir } from './sync/import.js'; import { RedactionError } from './sync/redact.js'; import { shareToGit, simpleGitShare } from './sync/share.js'; import { loadShareignore } from './sync/shareignore.js'; import { AdapterSnapshotProvider } from './sync/snapshot.js'; -import { TRE_MEM_DB_PATH, TRE_MEM_HOME } from './store/paths.js'; +import { CLAUDE_MEM_DB_PATH, TRE_MEM_DB_PATH, TRE_MEM_HOME } from './store/paths.js'; import { resolveProjectIdentity } from './store/aliases.js'; import { TreMemRepo } from './store/repo.js'; import { VERSION } from './version.js'; @@ -98,73 +106,163 @@ function printNothingToExportHint(opts: { const cli = cac('tre'); -cli.command('init', 'Initialize ~/.tre-mem/ and run schema migrations').action(() => { - const result = migrate(); - if (result.applied.length === 0) { - console.log(`tre-mem: already at schema v${result.toVersion} (${result.dbPath})`); - } else { +cli + .command('init', 'Set up ~/.tre-mem/, run migrations, and (with --all) wire this repo') + .option('--all', 'Also wire every installed AI tool in this repo and backfill past observations') + .option('--verbose', 'Show raw paths and migration details') + .action(async (flags: { all?: boolean; verbose?: boolean }) => { + const cwd = process.cwd(); + const result = migrate(); + + console.log(brand(theme)(`${BAMBOO} tre-mem`) + theme.dim(` v${VERSION}`)); console.log( - `tre-mem: migrated ${result.dbPath} from v${result.fromVersion} -> v${result.toVersion}`, + result.applied.length === 0 + ? ` ${theme.green('✓')} schema up to date (v${result.toVersion})` + : ` ${theme.green('✓')} migrated schema → v${result.toVersion}`, ); - console.log(` applied: ${result.applied.join(', ')}`); - } - console.log(` home: ${TRE_MEM_HOME}`); - console.log(` db: ${TRE_MEM_DB_PATH}`); + if (flags.verbose) { + console.log(theme.dim(` home: ${TRE_MEM_HOME}`)); + console.log(theme.dim(` db: ${TRE_MEM_DB_PATH}`)); + } - const cm = diagnoseClaudeMem(); - if (!cm.installed || !cm.compatible) { - console.log(''); - console.log(claudeMemGuidance(cm, theme.isColorSupported)); - } else { - console.log(` ${claudeMemGuidance(cm, theme.isColorSupported)}`); - console.log(` next: ${theme.cyan('tre backfill')} to branch-tag past observations`); - } -}); + const cm = diagnoseClaudeMem(); + const ready = cm.installed && cm.compatible; + console.log( + ready + ? ` ${theme.green('✓')} claude-mem detected — full branch-aware mode` + : ` ${theme.yellow('•')} claude-mem not ready — tre-mem runs in shared-only mode`, + ); + if (!ready) { + console.log(''); + console.log(claudeMemGuidance(cm, theme.isColorSupported)); + } -cli.command('doctor', 'Diagnose claude-mem connectivity and tre-mem setup').action(() => { - console.log(brand(theme)(`${BAMBOO} tre-mem doctor`)); - console.log(` version: ${VERSION}`); - console.log(` home: ${TRE_MEM_HOME}`); - console.log(` db: ${TRE_MEM_DB_PATH}`); - const cm = diagnoseClaudeMem(); - const mode = cm.installed && cm.compatible ? 'full' : 'shared-only'; - console.log(` mode: ${mode === 'full' ? theme.green('full') : theme.yellow('shared-only')}`); - console.log(''); - console.log(claudeMemGuidance(cm, theme.isColorSupported)); + if (flags.all) { + console.log(''); + console.log(theme.bold('Wiring this repo')); + for (const r of setupAll(cwd)) console.log(` ${r.message.replace(/\n/g, '\n ')}`); + if (ready) { + const adapter = new ClaudeMemAdapter(); + const repo = new TreMemRepo(); + try { + const cur = await currentBranch(cwd); + const bf = await backfill({ + project: basename(cwd), + cwd, + adapter, + repo, + fallbackBranch: cur === NO_GIT || isDetached(cur) ? undefined : cur, + }); + console.log(` ${theme.green('✓')} backfilled ${bf.tagged} past observation(s)`); + } finally { + adapter.close(); + repo.close(); + } + } + } - // Ingest health — installed ≠ ingesting. claude-mem ingests from the harnesses - // it wired (Claude Code, Codex, Gemini, Cursor, Antigravity), into one shared DB; - // the DB can exist yet hold no observations, in which case tre-mem is shared-only. - if (cm.installed && cm.compatible) { - const ingest = probeClaudeMemIngest(); console.log(''); - if (!ingest || ingest.health === 'none') { + console.log(theme.bold('Next steps')); + if (!ready) { + console.log(` 1. Install claude-mem, run one session, then ${theme.cyan('tre init')} again`); + } else if (!flags.all) { console.log( - theme.yellow(' ⚠ claude-mem is installed but has no observations yet — tre-mem will run'), + ` 1. ${theme.cyan('tre init --all')} — wire your AI tools + backfill in one step`, ); console.log( - ' in shared-only mode until claude-mem ingests a session (any wired harness).', + ` (or ${theme.cyan('tre setup claude-code')} + ${theme.cyan('tre backfill')} manually)`, ); + console.log(` 2. ${theme.cyan('tre status')} — sanity check`); + } else { console.log( - ` (claude-mem ingests from Claude Code, Codex, Gemini, Cursor, Antigravity. See ${theme.cyan('docs/CROSS-TOOL.md')}.)`, + ` 1. Start an AI coding session — tre-mem surfaces branch context automatically`, ); - } else if (ingest.health === 'stale') { - const days = Math.round(ingest.ageDays ?? 0); console.log( - theme.yellow( - ` ⚠ ${ingest.observations} observations, but newest is ${days}d old — ingest may have stopped.`, - ), + ` 2. Curate: ${theme.cyan('pin_fact')} / ${theme.cyan('graduate_fact')}, then ${theme.cyan('tre share')}`, ); + console.log(` 3. ${theme.cyan('tre status')} — see what's wired and shared`); + } + }); + +cli + .command('doctor', 'Diagnose claude-mem connectivity and tre-mem setup') + .option('--fix-hooks', 'Collapse duplicate SessionStart hooks so the banner shows once') + .option('--keep ', 'Which copy to keep when fixing: project (default) | global') + .action((flags: { fixHooks?: boolean; keep?: string }) => { + console.log(brand(theme)(`${BAMBOO} tre-mem doctor`)); + console.log(` version: ${VERSION}`); + console.log(` home: ${TRE_MEM_HOME}`); + console.log(` db: ${TRE_MEM_DB_PATH}`); + const cm = diagnoseClaudeMem(); + const mode = cm.installed && cm.compatible ? 'full' : 'shared-only'; + console.log( + ` mode: ${mode === 'full' ? theme.green('full') : theme.yellow('shared-only')}`, + ); + console.log(''); + console.log(claudeMemGuidance(cm, theme.isColorSupported)); + + // Ingest health — installed ≠ ingesting. claude-mem ingests from the harnesses + // it wired (Claude Code, Codex, Gemini, Cursor, Antigravity), into one shared DB; + // the DB can exist yet hold no observations, in which case tre-mem is shared-only. + if (cm.installed && cm.compatible) { + const ingest = probeClaudeMemIngest(); + console.log(''); + if (!ingest || ingest.health === 'none') { + console.log( + theme.yellow( + ' ⚠ claude-mem is installed but has no observations yet — tre-mem will run', + ), + ); + console.log( + ' in shared-only mode until claude-mem ingests a session (any wired harness).', + ); + console.log( + ` (claude-mem ingests from Claude Code, Codex, Gemini, Cursor, Antigravity. See ${theme.cyan('docs/CROSS-TOOL.md')}.)`, + ); + } else if (ingest.health === 'stale') { + const days = Math.round(ingest.ageDays ?? 0); + console.log( + theme.yellow( + ` ⚠ ${ingest.observations} observations, but newest is ${days}d old — ingest may have stopped.`, + ), + ); + } else { + const days = Math.round(ingest.ageDays ?? 0); + const when = days <= 0 ? 'today' : `${days}d ago`; + console.log( + theme.green(` ✓ ingesting: ${ingest.observations} observations, newest ${when}.`), + ); + } + } + + // Duplicate SessionStart hooks → the banner prints 2–3×. Detect + optionally fix. + const scans = scanSessionStartHooks(process.cwd()).filter((s) => s.count > 0); + const totalHooks = scans.reduce((n, s) => n + s.count, 0); + console.log(''); + if (totalHooks <= 1) { + console.log(theme.green(` ✓ SessionStart hook registered once (banner shows cleanly)`)); } else { - const days = Math.round(ingest.ageDays ?? 0); - const when = days <= 0 ? 'today' : `${days}d ago`; console.log( - theme.green(` ✓ ingesting: ${ingest.observations} observations, newest ${when}.`), + theme.yellow( + ` ⚠ SessionStart hook registered ${totalHooks}× — the banner prints multiple times:`, + ), ); + for (const s of scans) console.log(` ${s.scope}: ${s.path} (${s.count})`); + if (flags.fixHooks) { + const keep = flags.keep === 'global' ? 'global' : 'project'; + const result = dedupeSessionStartHooks(process.cwd(), keep); + for (const r of result.removed) + console.log(theme.green(` ✓ removed ${r.count} from ${r.path}`)); + if (result.kept) console.log(` kept: ${result.kept}`); + } else { + console.log( + ` → run ${theme.cyan('tre doctor --fix-hooks')} to collapse to one (keeps the project copy)`, + ); + } } - } - process.exitCode = cm.installed && cm.compatible ? 0 : 1; -}); + + process.exitCode = cm.installed && cm.compatible ? 0 : 1; + }); cli .command( @@ -175,91 +273,106 @@ cli const cwd = path ? resolve(path) : process.cwd(); const project = basename(cwd); const branch = await currentBranch(cwd); + const section = (title: string): void => console.log(`\n${theme.bold(title)}`); + const row = (label: string, value: string): void => + console.log(` ${theme.dim(label.padEnd(9))} ${value}`); migrate(); + const cm = diagnoseClaudeMem(); + const ready = cm.installed && cm.compatible; + const modeLabel = ready ? theme.green('full') : theme.yellow('shared-only'); + console.log(`${brand(theme)(`${BAMBOO} tre-mem`)} ${theme.dim('status ·')} ${modeLabel}`); + const repo = new TreMemRepo(); try { const identity = await resolveProjectIdentity(repo, cwd); const linked = identity.aliases.filter((p) => p !== identity.project); - console.log('tre-mem status:'); - console.log(` cwd: ${cwd}`); - console.log(` project: ${project}`); - if (identity.remote) console.log(` remote: ${identity.remote}`); - console.log(` branch: ${branch}`); + section('Identity'); + row('project', project); + row('branch', branch); + if (identity.remote) row('remote', identity.remote); if (linked.length > 0) { - console.log( - ` linked clones (${identity.aliases.length}): ${identity.aliases.join(', ')} ${theme.dim('(memory unioned via shared remote)')}`, - ); + row('clones', `${identity.aliases.length} linked: ${identity.aliases.join(', ')}`); + console.log(theme.dim(' (memory unioned across clones via shared remote)')); } - console.log(` branch_tag rows (project): ${repo.countBranchTagsAcross(identity.aliases)}`); + section('Memory'); + row('tags', `${repo.countBranchTagsAcross(identity.aliases)} tagged on this project`); const branches = repo.listBranchesForProjectAcross(identity.aliases); if (branches.length > 0) { - console.log(' branches with tags:'); - for (const b of branches) { - console.log(` - ${b.branch} (${b.count})`); - } + const shown = branches.slice(0, 6).map((b) => `${b.branch} (${b.count})`); + const more = branches.length > 6 ? ` … +${branches.length - 6} more` : ''; + row('branches', shown.join(' · ') + more); } - // Sync (Phase 2) status. const pins = repo.listPinsForProject(project); const sharedPins = pins.filter((p) => p.shared_at_epoch !== null).length; const pendingPins = pins.length - sharedPins; const graduatedCount = repo.listGraduated(project).length; - const syncDir = resolve(cwd, SYNC_DIR_NAME); - const hasSyncDir = existsSync(syncDir); + const hasSyncDir = existsSync(resolve(cwd, SYNC_DIR_NAME)); if (pins.length === 0 && graduatedCount === 0) { - console.log( - ` shared: nothing curated yet — ${theme.cyan('tre pin ')} / ${theme.cyan('tre graduate ')}, then ${theme.cyan('tre share')}`, + row( + 'curated', + `nothing yet — ${theme.cyan('pin_fact')} / ${theme.cyan('graduate_fact')}, then ${theme.cyan('tre share')}`, ); } else { - console.log( - ` shared to git: ${sharedPins} pin(s) shared · ${pendingPins} not shared yet · ${graduatedCount} graduated`, + row( + 'curated', + `${sharedPins} shared · ${pendingPins} pending · ${graduatedCount} graduated`, ); if (pendingPins > 0) { console.log( - ` → run ${theme.cyan('tre share')} to push ${pendingPins} unshared pin(s) to your team`, + theme.dim(` → run `) + + theme.cyan('tre share') + + theme.dim(` to publish ${pendingPins} pending pin(s)`), ); } } - console.log( - ` .tre-mem/: ${hasSyncDir ? `${syncDir} (travels through git)` : pins.length === 0 && graduatedCount === 0 ? '(not present — curate a fact first, then `tre share`)' : '(not present — run `tre share`)'}`, + row( + '.tre-mem/', + hasSyncDir + ? theme.dim('present (travels through git)') + : theme.dim( + pins.length === 0 && graduatedCount === 0 + ? 'not present — curate a fact, then `tre share`' + : 'not present — run `tre share`', + ), ); - // Cross-tool wiring (Phase 4): which harnesses are installed + wired. const present = detectTools(cwd).filter((t) => t.installed); if (present.length > 0) { + section('Tools'); const summary = present.map((t) => `${t.tool}${t.wired ? '✓' : '·'}`).join(' '); - console.log(` tools: ${summary} ${theme.dim('(✓ wired · = run `tre setup `)')}`); + console.log(` ${summary} ${theme.dim('(✓ wired · = run `tre setup `)')}`); } } finally { repo.close(); } - const cm = diagnoseClaudeMem(); - if (!cm.installed || !cm.compatible) { - console.log(''); + section('claude-mem'); + if (!ready) { console.log(claudeMemGuidance(cm, theme.isColorSupported)); return; } + const ingest = probeClaudeMemIngest(); const schema = cm.schemaVersion !== null ? `v${cm.schemaVersion}` : 'unknown'; - console.log(` claude-mem: schema ${schema} (tested up to v${cm.testedSchema})`); - if (cm.newerThanTested) { - console.log(claudeMemGuidance(cm, theme.isColorSupported)); - } - const adapter = new ClaudeMemAdapter(); - try { - const obs = adapter.getObservations({ projects: [project], limit: 1 }); - const head = obs[0]; + if (!ingest || ingest.health === 'none') { console.log( - head - ? ` claude-mem observations: >=1 (newest at ${head.created_at})` - : ' claude-mem observations: 0', + ` ${theme.yellow('•')} schema ${schema} · no observations yet (shared-only until ingest)`, + ); + } else if (ingest.health === 'stale') { + const days = Math.round(ingest.ageDays ?? 0); + console.log( + ` ${theme.yellow('•')} schema ${schema} · ${ingest.observations} obs · newest ${days}d old`, + ); + } else { + const days = Math.round(ingest.ageDays ?? 0); + console.log( + ` ${theme.green('✓')} schema ${schema} · ${ingest.observations} obs · newest ${days <= 0 ? 'today' : `${days}d ago`}`, ); - } finally { - adapter.close(); } + if (cm.newerThanTested) console.log(claudeMemGuidance(cm, theme.isColorSupported)); }); cli @@ -444,6 +557,95 @@ cli }, ); +cli + .command('unpin ', 'Remove a pin from a branch (writes a tombstone if shared)') + .option('--cwd ', 'Repo root to derive project + branch from') + .option('--project ', 'Override project slug') + .option('--branch ', 'Override branch') + .action( + async ( + observationIdRaw: string, + flags: { cwd?: string; project?: string; branch?: string }, + ) => { + const observationId = Number.parseInt(observationIdRaw, 10); + if (!Number.isInteger(observationId) || observationId <= 0) { + process.stderr.write(`tre unpin: invalid observation id "${observationIdRaw}"\n`); + process.exit(2); + } + const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd(); + const project = flags.project ?? basename(cwd); + const branch = flags.branch ?? (await currentBranch(cwd)); + + migrate(); + const repo = new TreMemRepo(); + try { + const result = forgetPin({ + repo, + dir: resolve(cwd, SYNC_DIR_NAME), + project, + branch, + observation_id: observationId, + now: Math.floor(Date.now() / 1000), + author: await gitAuthor(cwd), + }); + if (result.removed === 0) { + console.log(`tre-mem: no pin for observation ${observationId} on ${project}/${branch}.`); + } else { + console.log( + `tre-mem: unpinned observation ${observationId} on ${project}/${branch} (${result.removed} pin(s) removed)`, + ); + if (result.tombstoned) { + console.log( + ' wrote a tombstone to .tre-mem/ — run `tre share` to publish the removal.', + ); + } + } + } finally { + repo.close(); + } + }, + ); + +cli + .command( + 'ungraduate ', + 'Remove a project-wide graduated fact (writes a tombstone if shared)', + ) + .option('--cwd ', 'Repo root to derive project from') + .option('--project ', 'Override project slug') + .action(async (observationIdRaw: string, flags: { cwd?: string; project?: string }) => { + const observationId = Number.parseInt(observationIdRaw, 10); + if (!Number.isInteger(observationId) || observationId <= 0) { + process.stderr.write(`tre ungraduate: invalid observation id "${observationIdRaw}"\n`); + process.exit(2); + } + const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd(); + const project = flags.project ?? basename(cwd); + + migrate(); + const repo = new TreMemRepo(); + try { + const result = forgetGraduated({ + repo, + dir: resolve(cwd, SYNC_DIR_NAME), + project, + observation_id: observationId, + now: Math.floor(Date.now() / 1000), + author: await gitAuthor(cwd), + }); + if (result.removed === 0) { + console.log(`tre-mem: no graduated fact for observation ${observationId} in ${project}.`); + } else { + console.log(`tre-mem: ungraduated observation ${observationId} from ${project}.`); + if (result.tombstoned) { + console.log(' wrote a tombstone to .tre-mem/ — run `tre share` to publish the removal.'); + } + } + } finally { + repo.close(); + } + }); + cli .command('list-branches', 'List branches with tag counts for a project') .option('--cwd ', 'Repo root to derive project from') @@ -1163,6 +1365,21 @@ function pinnedFacts(repo: TreMemRepo, project: string, branch: string): PinnedF }); } +/** + * How long to defer emitting the SessionStart banner. claude-mem also prints at + * session start (Claude Code only); deferring briefly makes tre-mem render BELOW + * it instead of racing. Override with `TRE_MEM_HOOK_DELAY_MS` (0 disables). + * Default: 250ms when claude-mem is present on Claude Code, otherwise 0. + */ +function sessionHookDelayMs(format: HookFormat): number { + const raw = process.env.TRE_MEM_HOOK_DELAY_MS; + if (raw !== undefined && raw.trim() !== '') { + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n >= 0 ? n : 0; + } + return format === 'claude' && existsSync(CLAUDE_MEM_DB_PATH) ? 250 : 0; +} + async function runSessionStartHookCli(format: HookFormat): Promise { try { const input = await readStdinJson(); @@ -1176,6 +1393,9 @@ async function runSessionStartHookCli(format: HookFormat): Promise { pinned: ({ project, branch }) => pinnedFacts(repo, project, branch), dashboardUrl, }); + // Defer so claude-mem's banner lands first (see sessionHookDelayMs). + const delay = sessionHookDelayMs(format); + if (delay > 0) await new Promise((r) => setTimeout(r, delay)); process.stdout.write( `${JSON.stringify(sessionStartEnvelope(format, result.message, result.display))}\n`, ); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 3109f2a..10eb0f4 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -10,6 +10,7 @@ import { searchBranchContext } from '../retrieval/search.js'; import { resolveProjectIdentity } from '../store/aliases.js'; import type { TreMemRepo } from '../store/repo.js'; import { exportSync, type SnapshotProvider } from '../sync/export.js'; +import { forgetGraduated, forgetPin } from '../sync/forget.js'; import { SYNC_DIR_NAME, ensureSyncScaffold } from '../sync/layout.js'; import { RedactionError } from '../sync/redact.js'; import { shareToGit, simpleGitShare } from '../sync/share.js'; @@ -123,6 +124,37 @@ export interface GraduateFactResult { hint: string; } +export interface UnpinFactInput { + observation_id: number; + branch?: string; + project?: string; + cwd?: string; +} + +export interface UnpinFactResult { + project: string; + branch: string; + observation_id: number; + removed_count: number; + /** True when a tombstone was written to propagate the removal to teammates. */ + tombstoned: boolean; + hint: string; +} + +export interface UngraduateFactInput { + observation_id: number; + project?: string; + cwd?: string; +} + +export interface UngraduateFactResult { + project: string; + observation_id: number; + removed: boolean; + tombstoned: boolean; + hint: string; +} + export interface ExportMemoryInput { cwd?: string; project?: string; @@ -266,6 +298,38 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [ required: ['observation_id'], }, }, + { + name: 'unpin_fact', + description: + 'Remove a pin from a branch (the inverse of pin_fact). If the pin was already shared, writes a tombstone to .tre-mem/ so teammates lose it on their next import — call export_memory afterward to publish the removal.', + inputSchema: { + type: 'object', + properties: { + observation_id: { type: 'integer', minimum: 1 }, + branch: { + type: 'string', + description: 'Branch to unpin from (defaults to current branch)', + }, + project: { type: 'string', description: 'Project slug (defaults to basename of cwd)' }, + cwd: { type: 'string', description: 'Working directory used to derive project + branch' }, + }, + required: ['observation_id'], + }, + }, + { + name: 'ungraduate_fact', + description: + 'Remove a project-wide graduated fact (the inverse of graduate_fact). Use this when a fact becomes wrong after PR feedback or QC. If the fact was shared, writes a tombstone to .tre-mem/ so teammates lose it on their next import — call export_memory afterward to publish the removal. To correct a fact, ungraduate it then graduate_fact the corrected observation.', + inputSchema: { + type: 'object', + properties: { + observation_id: { type: 'integer', minimum: 1 }, + project: { type: 'string', description: 'Project slug (defaults to basename of cwd)' }, + cwd: { type: 'string', description: 'Working directory used to derive project' }, + }, + required: ['observation_id'], + }, + }, { name: 'export_memory', description: @@ -441,6 +505,74 @@ export async function graduateFact( }; } +export async function unpinFact(deps: ToolDeps, input: UnpinFactInput): Promise { + if (!Number.isInteger(input.observation_id) || input.observation_id <= 0) { + throw new Error(`unpin_fact: invalid observation_id "${String(input.observation_id)}"`); + } + const cwd = resolveCwd(deps, input.cwd); + const project = input.project ?? basename(cwd); // WRITE key — per-clone removal + const branch = input.branch ?? (await resolveBranch(deps, cwd)); + const dir = resolve(cwd, SYNC_DIR_NAME); + const now = (deps.now ?? defaultNow)(); + const author = await gitAuthor(cwd); + + const result = forgetPin({ + repo: deps.repo, + dir, + project, + branch, + observation_id: input.observation_id, + now, + author, + }); + + return { + project, + branch, + observation_id: input.observation_id, + removed_count: result.removed, + tombstoned: result.tombstoned, + hint: result.tombstoned + ? 'Removed locally and wrote a tombstone to .tre-mem/. Call export_memory to publish the removal to your team.' + : 'Removed locally. Nothing was shared, so no tombstone was needed.', + }; +} + +export async function ungraduateFact( + deps: ToolDeps, + input: UngraduateFactInput, +): Promise { + if (!Number.isInteger(input.observation_id) || input.observation_id <= 0) { + throw new Error(`ungraduate_fact: invalid observation_id "${String(input.observation_id)}"`); + } + const cwd = resolveCwd(deps, input.cwd); + const project = input.project ?? basename(cwd); // WRITE key — per-clone removal + const dir = resolve(cwd, SYNC_DIR_NAME); + const now = (deps.now ?? defaultNow)(); + const author = await gitAuthor(cwd); + + const result = forgetGraduated({ + repo: deps.repo, + dir, + project, + observation_id: input.observation_id, + now, + author, + }); + + return { + project, + observation_id: input.observation_id, + removed: result.removed > 0, + tombstoned: result.tombstoned, + hint: result.tombstoned + ? 'Removed locally and wrote a tombstone to .tre-mem/. Call export_memory to publish the removal to your team.' + : result.removed > 0 + ? 'Removed locally. Nothing was shared, so no tombstone was needed.' + : 'No graduated fact found for that observation_id.', + }; +} + export async function exportMemory( deps: ToolDeps, input: ExportMemoryInput, @@ -581,6 +713,10 @@ export async function callTool( return pinFact(deps, args as unknown as PinFactInput); case 'graduate_fact': return graduateFact(deps, args as unknown as GraduateFactInput); + case 'unpin_fact': + return unpinFact(deps, args as unknown as UnpinFactInput); + case 'ungraduate_fact': + return ungraduateFact(deps, args as unknown as UngraduateFactInput); default: throw new Error(`unknown tool "${name}"`); } diff --git a/src/setup.ts b/src/setup.ts index e0b9b88..9957ab3 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,4 +1,5 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { antigravityMcpPaths, registerAntigravityMcp } from './tooling/antigravity.js'; @@ -126,6 +127,112 @@ export function setupClaudeCode(cwd: string, opts: SetupOptions = {}): SetupResu }; } +// --- Duplicate SessionStart hook detection (banner shows once, below claude-mem) --- + +/** tre-mem's SessionStart hook always runs `… hook session-start`; nothing else does. */ +const SESSION_HOOK_RE = /hook session-start/; + +export interface SessionHookScan { + path: string; + /** Where this file sits — used to decide which copy to keep when deduping. */ + scope: 'project' | 'global'; + count: number; +} + +/** The Claude Code settings files that can carry a tre-mem SessionStart hook. */ +export function claudeSettingsFiles( + cwd: string, +): Array<{ path: string; scope: 'project' | 'global' }> { + return [ + { path: join(cwd, '.claude', 'settings.json'), scope: 'project' }, + { path: join(homedir(), '.claude', 'settings.json'), scope: 'global' }, + ]; +} + +function countSessionHooks(settings: ClaudeSettings): number { + let n = 0; + for (const entry of settings.hooks?.SessionStart ?? []) { + for (const h of entry.hooks ?? []) { + if (typeof h.command === 'string' && SESSION_HOOK_RE.test(h.command)) n += 1; + } + } + return n; +} + +/** + * Count tre-mem SessionStart hooks in each known settings file (0 when absent). + * `files` is injectable for tests; production passes the resolved cwd-based set. + */ +export function scanSessionStartHooks( + cwd: string, + files: Array<{ path: string; scope: 'project' | 'global' }> = claudeSettingsFiles(cwd), +): SessionHookScan[] { + return files.map(({ path, scope }) => { + if (!existsSync(path)) return { path, scope, count: 0 }; + try { + return { path, scope, count: countSessionHooks(readSettings(path)) }; + } catch { + return { path, scope, count: 0 }; + } + }); +} + +function stripSessionHooks(settings: ClaudeSettings): { next: ClaudeSettings; removed: number } { + const entries = settings.hooks?.SessionStart; + if (!entries) return { next: settings, removed: 0 }; + let removed = 0; + const nextEntries = entries + .map((entry) => ({ + ...entry, + hooks: (entry.hooks ?? []).filter((h) => { + const match = typeof h.command === 'string' && SESSION_HOOK_RE.test(h.command); + if (match) removed += 1; + return !match; + }), + })) + .filter((entry) => (entry.hooks?.length ?? 0) > 0); + if (removed === 0) return { next: settings, removed: 0 }; + return { + next: { ...settings, hooks: { ...settings.hooks, SessionStart: nextEntries } }, + removed, + }; +} + +export interface DedupeSessionHooksResult { + kept: string | null; + removed: Array<{ path: string; count: number }>; +} + +/** + * Collapse duplicate tre-mem SessionStart hooks down to one. Keeps the `keep` + * scope's copy and strips the hook from the others, so the banner renders + * exactly once per session. Default keep is `project`: the repo-local + * `.claude/settings.json` is the canonical, committed, team-shared wiring that + * `tre setup` writes (and it uses the portable `tre` command). A global copy is + * usually a dev leftover — often an absolute `…/dist/cli.js` path that fires this + * one repo's build in every project — so removing it is the safe default. + */ +export function dedupeSessionStartHooks( + cwd: string, + keep: 'project' | 'global' = 'project', + files: Array<{ path: string; scope: 'project' | 'global' }> = claudeSettingsFiles(cwd), +): DedupeSessionHooksResult { + const present = scanSessionStartHooks(cwd, files).filter((s) => s.count > 0); + if (present.length <= 1) return { kept: present[0]?.path ?? null, removed: [] }; + + const keepScan = present.find((s) => s.scope === keep) ?? present[0]; + const removed: Array<{ path: string; count: number }> = []; + for (const scan of present) { + if (scan.path === keepScan?.path) continue; + const { next, removed: n } = stripSessionHooks(readSettings(scan.path)); + if (n > 0) { + writeFileSync(scan.path, JSON.stringify(next, null, 2) + '\n', 'utf8'); + removed.push({ path: scan.path, count: n }); + } + } + return { kept: keepScan?.path ?? null, removed }; +} + const CONSUME_NOTE = ' This registers tre-mem (branch-aware team memory) over MCP. Full branch search\n' + ' needs claude-mem installed too (it ingests observations) — `tre doctor` shows the mode.'; @@ -248,6 +355,20 @@ function configHasTreMem(path: string): boolean { } } +/** + * Claude Code is wired when its settings carry tre-mem's SessionStart hook. + * The hook command is `tre hook session-start` (no literal "tre-mem" string), so + * a plain substring check misses it — match the hook command instead. + */ +function claudeCodeWired(path: string): boolean { + if (!existsSync(path)) return false; + try { + return countSessionHooks(readSettings(path)) > 0 || configHasTreMem(path); + } catch { + return false; + } +} + /** * Detect which harnesses are installed on this machine and whether tre-mem is * already wired into each. `cwd` is used for the repo-local Claude Code config. @@ -261,7 +382,7 @@ export function detectTools(cwd: string): ToolPresence[] { { tool: 'claude-code', installed: existsSync(join(cwd, '.claude')) || existsSync(claudePath), - wired: configHasTreMem(claudePath), + wired: claudeCodeWired(claudePath), configPath: claudePath, }, { diff --git a/src/store/repo.ts b/src/store/repo.ts index 8bd070a..0e3816f 100644 --- a/src/store/repo.ts +++ b/src/store/repo.ts @@ -365,6 +365,38 @@ export class TreMemRepo { .run(contentHash, sharedAtEpoch, id); } + // --- Removal (forget / tombstone) --- + + deletePinById(id: number): boolean { + return this.db.prepare(`DELETE FROM branch_pin WHERE id = ?`).run(id).changes > 0; + } + + /** Remove every pin matching (project, branch, observation_id). Returns rows removed. */ + deletePinsByObservation(project: string, branch: string, observationId: number): number { + return this.db + .prepare(`DELETE FROM branch_pin WHERE project = ? AND branch = ? AND observation_id = ?`) + .run(project, branch, observationId).changes; + } + + /** Used by import to honor a teammate's pin tombstone. Returns rows removed. */ + deletePinsByContentHash(contentHash: string): number { + return this.db.prepare(`DELETE FROM branch_pin WHERE content_hash = ?`).run(contentHash) + .changes; + } + + deleteGraduatedByObservation(project: string, observationId: number): boolean { + return ( + this.db + .prepare(`DELETE FROM graduated WHERE project = ? AND observation_id = ?`) + .run(project, observationId).changes > 0 + ); + } + + /** Used by import to honor a teammate's graduated tombstone. Returns rows removed. */ + deleteGraduatedByContentHash(contentHash: string): number { + return this.db.prepare(`DELETE FROM graduated WHERE content_hash = ?`).run(contentHash).changes; + } + countUnsharedPins(project: string): number { const row = this.db .prepare(`SELECT COUNT(*) AS n FROM branch_pin WHERE project = ? AND shared_at_epoch IS NULL`) diff --git a/src/sync/forget.ts b/src/sync/forget.ts new file mode 100644 index 0000000..9c7037b --- /dev/null +++ b/src/sync/forget.ts @@ -0,0 +1,146 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { log } from '../log/logger.js'; +import type { TreMemRepo } from '../store/repo.js'; + +import { parseSyncLine, serializeSyncRecord, type TombstoneRecord } from './format.js'; +import { branchFilePath, graduatedFilePath, writeFileAtomic } from './layout.js'; + +export interface ForgetResult { + /** Rows removed from the local sidecar DB. */ + removed: number; + /** True when a tombstone was written to `.tre-mem/` to propagate the removal. */ + tombstoned: boolean; +} + +export interface ForgetPinOptions { + repo: TreMemRepo; + /** The committed `.tre-mem/` directory (same `dir` convention as exportSync). */ + dir: string; + project: string; + branch: string; + observation_id: number; + now: number; + author?: string | null; +} + +export interface ForgetGraduatedOptions { + repo: TreMemRepo; + /** The committed `.tre-mem/` directory (same `dir` convention as exportSync). */ + dir: string; + project: string; + observation_id: number; + now: number; + author?: string | null; +} + +/** + * Append a tombstone line to a shared JSONL file unless an identical one is + * already there. Dedup is by (`kind === 'tombstone'`, `content_hash`) — NOT by + * content_hash alone, since the fact being removed carries the same hash. + * Returns true when a line was written. + */ +function appendTombstone(filePath: string, record: TombstoneRecord): boolean { + const existingLines = existsSync(filePath) + ? readFileSync(filePath, 'utf8') + .split('\n') + .filter((l) => l.trim() !== '') + : []; + for (const line of existingLines) { + try { + const parsed = parseSyncLine(line); + if (parsed.kind === 'tombstone' && parsed.content_hash === record.content_hash) { + return false; // already tombstoned — idempotent + } + } catch { + /* unparseable lines can't be a matching tombstone; ignore */ + } + } + mkdirSync(dirname(filePath), { recursive: true }); + writeFileAtomic(filePath, [...existingLines, serializeSyncRecord(record)].join('\n') + '\n'); + return true; +} + +/** + * Forget pins for (project, branch, observation_id): write a propagating + * tombstone for each *shared* pin (so teammates lose it on next import), then + * delete the local rows. Pins that were never shared have no shared line to + * remove, so they are simply deleted locally (no tombstone). + */ +export function forgetPin(opts: ForgetPinOptions): ForgetResult { + const { repo, dir, project, branch, observation_id, now } = opts; + const author = opts.author ?? null; + + const shared = repo + .listPinsForBranch(project, branch) + .filter((p) => p.observation_id === observation_id && p.content_hash !== null); + + let tombstoned = false; + if (existsSync(dir)) { + for (const pin of shared) { + const wrote = appendTombstone(branchFilePath(dir, branch), { + schema: 1, + kind: 'tombstone', + content_hash: pin.content_hash as string, + target_kind: 'pin', + project, + branch, + observation_id, + author, + tombstoned_at_epoch: now, + }); + tombstoned = tombstoned || wrote; + } + } + + const removed = repo.deletePinsByObservation(project, branch, observation_id); + if (removed > 0) { + log({ + level: 'info', + component: 'sync', + event: 'forget_pin', + fields: { project, branch, observation_id, removed, tombstoned }, + }); + } + return { removed, tombstoned }; +} + +/** + * Forget a graduated fact (project, observation_id): write a propagating + * tombstone if it was shared, then delete the local row. Re-graduating with + * corrected content is the supported "update" path (new content → new hash). + */ +export function forgetGraduated(opts: ForgetGraduatedOptions): ForgetResult { + const { repo, dir, project, observation_id, now } = opts; + const author = opts.author ?? null; + + const grad = repo.getGraduated(project, observation_id); + if (grad === null) return { removed: 0, tombstoned: false }; + + let tombstoned = false; + if (grad.content_hash !== null && existsSync(dir)) { + tombstoned = appendTombstone(graduatedFilePath(dir), { + schema: 1, + kind: 'tombstone', + content_hash: grad.content_hash, + target_kind: 'graduated', + project, + branch: null, + observation_id, + author, + tombstoned_at_epoch: now, + }); + } + + const removed = repo.deleteGraduatedByObservation(project, observation_id) ? 1 : 0; + if (removed > 0) { + log({ + level: 'info', + component: 'sync', + event: 'forget_graduated', + fields: { project, observation_id, removed, tombstoned }, + }); + } + return { removed, tombstoned }; +} diff --git a/src/sync/format.ts b/src/sync/format.ts index cbd901a..fedf447 100644 --- a/src/sync/format.ts +++ b/src/sync/format.ts @@ -12,7 +12,7 @@ export const SYNC_SCHEMA_VERSION = 1; const UNIT = '\x1f'; // ASCII Unit Separator — safe payload delimiter for hashing. -export type SyncKind = 'pin' | 'graduated'; +export type SyncKind = 'pin' | 'graduated' | 'tombstone'; export interface PinRecord { schema: number; @@ -41,7 +41,29 @@ export interface GraduatedRecord { graduated_at_epoch: number; } -export type SyncRecord = PinRecord | GraduatedRecord; +/** + * A removal marker. Travels through git like a pin/graduated row and tells every + * clone to delete the fact whose `content_hash` it carries on the next import. + * Kept at schema v1 deliberately: pre-tombstone clients hit "unknown kind" in + * `parseSyncLine`, count an error, and skip it — graceful degradation, no break. + * Lives in the SAME jsonl file as its target so per-file import resolves cleanly + * (pin → branches/.jsonl, graduated → graduated.jsonl). + */ +export interface TombstoneRecord { + schema: number; + kind: 'tombstone'; + /** content_hash of the pin/graduated fact being removed. */ + content_hash: string; + target_kind: 'pin' | 'graduated'; + project: string; + /** Set for pin tombstones; null for graduated. Human-readable diff aid. */ + branch: string | null; + observation_id: number | null; + author: string | null; + tombstoned_at_epoch: number; +} + +export type SyncRecord = PinRecord | GraduatedRecord | TombstoneRecord; // Fixed key orders → deterministic serialization (stable git diffs). const PIN_KEYS: ReadonlyArray = [ @@ -71,6 +93,18 @@ const GRADUATED_KEYS: ReadonlyArray = [ 'graduated_at_epoch', ]; +const TOMBSTONE_KEYS: ReadonlyArray = [ + 'schema', + 'kind', + 'content_hash', + 'target_kind', + 'project', + 'branch', + 'observation_id', + 'author', + 'tombstoned_at_epoch', +]; + export interface PinHashInput { project: string; branch: string; @@ -126,6 +160,9 @@ export function serializeSyncRecord(record: SyncRecord): string { if (record.kind === 'pin') { return JSON.stringify(record, PIN_KEYS as string[]); } + if (record.kind === 'tombstone') { + return JSON.stringify(record, TOMBSTONE_KEYS as string[]); + } return JSON.stringify(record, GRADUATED_KEYS as string[]); } @@ -203,5 +240,22 @@ export function parseSyncLine(line: string): SyncRecord { graduated_at_epoch: asNumber(obj.graduated_at_epoch, 'graduated_at_epoch'), }; } + if (kind === 'tombstone') { + const targetKind = obj.target_kind; + if (targetKind !== 'pin' && targetKind !== 'graduated') { + throw new Error(`sync record: tombstone target_kind must be "pin" or "graduated"`); + } + return { + schema, + kind: 'tombstone', + content_hash: asString(obj.content_hash, 'content_hash'), + target_kind: targetKind, + project: asString(obj.project, 'project'), + branch: asNullableString(obj.branch, 'branch'), + observation_id: asNullableNumber(obj.observation_id, 'observation_id'), + author: asNullableString(obj.author, 'author'), + tombstoned_at_epoch: asNumber(obj.tombstoned_at_epoch, 'tombstoned_at_epoch'), + }; + } throw new Error(`sync record: unknown kind "${String(kind)}"`); } diff --git a/src/sync/import.ts b/src/sync/import.ts index 2dc8a50..9725571 100644 --- a/src/sync/import.ts +++ b/src/sync/import.ts @@ -5,7 +5,7 @@ import { basename, join } from 'node:path'; import { log } from '../log/logger.js'; import type { TreMemRepo } from '../store/repo.js'; -import { parseSyncLine } from './format.js'; +import { parseSyncLine, type SyncRecord, type TombstoneRecord } from './format.js'; import { graduatedFilePath } from './layout.js'; export interface ImportOptions { @@ -20,6 +20,8 @@ export interface ImportFileResult { file: string; inserted: number; duplicates: number; + /** Rows removed because a tombstone in this file matched them. */ + tombstoned: number; errors: number; /** True when the file was skipped because its SHA matched the last import. */ unchanged: boolean; @@ -29,6 +31,8 @@ export interface ImportResult { dir: string; pins: number; graduated: number; + /** Total rows removed by tombstones across all files. */ + tombstoned: number; files: ImportFileResult[]; } @@ -61,7 +65,14 @@ function importFile( const prior = repo.getImportState(filePath); if (!force && prior !== null && prior.last_sha === sha) { return { - result: { file: filePath, inserted: 0, duplicates: 0, errors: 0, unchanged: true }, + result: { + file: filePath, + inserted: 0, + duplicates: 0, + tombstoned: 0, + errors: 0, + unchanged: true, + }, pins: 0, graduated: 0, }; @@ -69,19 +80,37 @@ function importFile( let inserted = 0; let duplicates = 0; + let tombstoned = 0; let errors = 0; let pins = 0; let graduated = 0; + // Pass 1: parse every line; gather tombstoned content_hashes up front so an + // insert can never resurrect a fact a later line removes (order-independent). + const records: SyncRecord[] = []; + const tombstones: TombstoneRecord[] = []; + const tombstonedHashes = new Set(); for (const line of content.split('\n')) { if (line.trim() === '') continue; - let record; + let record: SyncRecord; try { record = parseSyncLine(line); } catch { errors++; continue; } + if (record.kind === 'tombstone') { + tombstones.push(record); + tombstonedHashes.add(record.content_hash); + } else { + records.push(record); + } + } + + // Pass 2: insert facts that are not tombstoned in this file. + for (const record of records) { + if (record.kind === 'tombstone') continue; // already split out; keeps types narrow + if (tombstonedHashes.has(record.content_hash)) continue; if (record.kind === 'pin') { const added = repo.importPin({ project: record.project, @@ -120,9 +149,18 @@ function importFile( } } + // Apply tombstones last: remove any row (this file's or a prior import's) the + // tombstone targets. Idempotent — re-running removes nothing extra. + for (const t of tombstones) { + tombstoned += + t.target_kind === 'pin' + ? repo.deletePinsByContentHash(t.content_hash) + : repo.deleteGraduatedByContentHash(t.content_hash); + } + repo.upsertImportState(filePath, sha, now); return { - result: { file: filePath, inserted, duplicates, errors, unchanged: false }, + result: { file: filePath, inserted, duplicates, tombstoned, errors, unchanged: false }, pins, graduated, }; @@ -137,7 +175,7 @@ function importFile( export function importDir(opts: ImportOptions): ImportResult { const { repo, dir, now, force = false } = opts; if (!existsSync(dir)) { - return { dir, pins: 0, graduated: 0, files: [] }; + return { dir, pins: 0, graduated: 0, tombstoned: 0, files: [] }; } const files: ImportFileResult[] = []; @@ -149,15 +187,16 @@ export function importDir(opts: ImportOptions): ImportResult { pins += p; graduated += g; } + const tombstoned = files.reduce((sum, f) => sum + f.tombstoned, 0); const errors = files.reduce((sum, f) => sum + f.errors, 0); // Only log when something actually moved — keeps the per-session import quiet. - if (pins > 0 || graduated > 0 || errors > 0) { + if (pins > 0 || graduated > 0 || tombstoned > 0 || errors > 0) { log({ level: errors > 0 ? 'warn' : 'info', component: 'sync', event: 'import', - fields: { dir: basename(dir), pins, graduated, files: files.length, errors }, + fields: { dir: basename(dir), pins, graduated, tombstoned, files: files.length, errors }, }); } - return { dir, pins, graduated, files }; + return { dir, pins, graduated, tombstoned, files }; } diff --git a/src/sync/layout.ts b/src/sync/layout.ts index bf1eff4..51dd9cf 100644 --- a/src/sync/layout.ts +++ b/src/sync/layout.ts @@ -24,6 +24,9 @@ them on \`git clone\`. - \`graduated.jsonl\` — facts promoted to repo-wide knowledge - \`.shareignore\` — text patterns that block matching pins from being exported +Removals travel too: forgetting a shared fact appends a \`tombstone\` line, and the +next \`tre import\` (or \`git pull\`) drops the fact on every teammate's machine. + ## Workflow \`\`\`bash diff --git a/src/sync/read.ts b/src/sync/read.ts index 1e27ad3..3dc3d78 100644 --- a/src/sync/read.ts +++ b/src/sync/read.ts @@ -44,11 +44,35 @@ export function readSyncRecords(dir: string): SyncRecord[] { return records; } -/** Same read, split into the two record kinds for callers that want them apart. */ +/** + * Read the *live* facts from a `.tre-mem/` directory: every pin + graduated + * record minus the ones a tombstone removed (and minus the tombstones + * themselves). The web layer uses this so it never surfaces a fact a teammate + * already forgot. + */ +export function readLiveSyncRecords(dir: string): Array { + const records = readSyncRecords(dir); + const tombstoned = new Set(); + for (const record of records) { + if (record.kind === 'tombstone') tombstoned.add(record.content_hash); + } + const live: Array = []; + for (const record of records) { + if (record.kind === 'tombstone') continue; + if (tombstoned.has(record.content_hash)) continue; + live.push(record); + } + return live; +} + +/** + * Same read, split into the two record kinds for callers that want them apart. + * Tombstones are applied (a forgotten fact never surfaces). + */ export function readSyncDir(dir: string): { pins: PinRecord[]; graduated: GraduatedRecord[] } { const pins: PinRecord[] = []; const graduated: GraduatedRecord[] = []; - for (const record of readSyncRecords(dir)) { + for (const record of readLiveSyncRecords(dir)) { if (record.kind === 'pin') pins.push(record); else graduated.push(record); } diff --git a/src/version.ts b/src/version.ts index a1c8edc..c72f190 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.10.0'; +export const VERSION = '0.11.0'; diff --git a/src/web/api.ts b/src/web/api.ts index b57ae00..ceb32bc 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -6,7 +6,7 @@ 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 { readLiveSyncRecords } from '../sync/read.js'; import { aggregateContributors, @@ -296,7 +296,7 @@ const ROUTES: Route[] = [ handler: async (_req, res, ctx) => { const project = resolveProject(ctx); const dir = join(ctx.deps.cwd, SYNC_DIR_NAME); - const records = readSyncRecords(dir); + const records = readLiveSyncRecords(dir); const { contributors, attributed_total, unattributed_total } = aggregateContributors( records, project, @@ -331,7 +331,7 @@ const ROUTES: Route[] = [ handler: async (_req, res, ctx) => { const project = resolveProject(ctx); const dir = join(ctx.deps.cwd, SYNC_DIR_NAME); - const records = readSyncRecords(dir); + const records = readLiveSyncRecords(dir); const hasFacts = records.some((r) => r.project === project); if (!hasFacts && fallbackEnabled(ctx)) { const perBranch = await gitBranchAuthors(ctx.deps.repo, ctx.deps.cwd, project); diff --git a/src/web/grove.ts b/src/web/grove.ts index 837966a..f835412 100644 --- a/src/web/grove.ts +++ b/src/web/grove.ts @@ -1,4 +1,7 @@ -import type { SyncRecord } from '../sync/format.js'; +import type { GraduatedRecord, PinRecord } from '../sync/format.js'; + +/** Live facts only — grove never operates on tombstone records. */ +type LiveRecord = PinRecord | GraduatedRecord; /** * Grove = the contributor view of a repo's shared memory. Pure, HTTP-free @@ -89,11 +92,11 @@ interface Acc { epochs: number[]; } -function recordEpoch(r: SyncRecord): number { +function recordEpoch(r: LiveRecord): number { return r.kind === 'pin' ? r.tagged_at_epoch : r.graduated_at_epoch; } -function recordBranch(r: SyncRecord): string { +function recordBranch(r: LiveRecord): string { return r.kind === 'pin' ? r.branch : r.graduated_from_branch; } @@ -116,7 +119,7 @@ function longestStreakDays(epochs: number[]): number { * value_score desc, tie-broken by most-recent activity. */ export function aggregateContributors( - records: SyncRecord[], + records: LiveRecord[], project: string, now: number, ): { contributors: ContributorStat[]; attributed_total: number; unattributed_total: number } { @@ -217,7 +220,7 @@ function assignBadges(contributors: ContributorStat[], accs: Map, n * and how branches graduate into the trunk. */ export function buildGraph( - records: SyncRecord[], + records: LiveRecord[], extraBranches: string[], project: string, ): { nodes: GraphNode[]; edges: GraphEdge[] } { diff --git a/test/mcp-tools.test.ts b/test/mcp-tools.test.ts index 53e9251..993f466 100644 --- a/test/mcp-tools.test.ts +++ b/test/mcp-tools.test.ts @@ -13,6 +13,8 @@ import { graduateFact, listBranches, pinFact, + ungraduateFact, + unpinFact, type ToolDeps, } from '../src/mcp/tools.js'; import { migrate } from '../src/store/migrate.js'; @@ -53,13 +55,15 @@ describe('MCP tools', () => { rmSync(tmp, { recursive: true, force: true }); }); - it('exposes exactly 7 tool definitions with required fields', () => { + it('exposes exactly 9 tool definitions with required fields', () => { expect(TOOL_DEFINITIONS.map((t) => t.name)).toEqual([ 'get_branch_context', 'get_branch_timeline', 'list_branches', 'pin_fact', 'graduate_fact', + 'unpin_fact', + 'ungraduate_fact', 'export_memory', 'get_share_status', ]); @@ -140,6 +144,38 @@ describe('MCP tools', () => { expect(all[0]!.graduated_from_branch).toBe('main'); }); + it('unpin_fact: removes a pin locally (unshared → no tombstone)', async () => { + await pinFact(deps, { observation_id: 3, note: 'temp' }); + expect(repo.listPinsForBranch(PROJECT, FEATURE)).toHaveLength(1); + + const result = await unpinFact(deps, { observation_id: 3 }); + expect(result.removed_count).toBe(1); + expect(result.tombstoned).toBe(false); + expect(repo.listPinsForBranch(PROJECT, FEATURE)).toHaveLength(0); + }); + + it('unpin_fact: rejects bad observation ids', async () => { + await expect(unpinFact(deps, { observation_id: 0 })).rejects.toThrow(/invalid observation_id/); + }); + + it('ungraduate_fact: removes a graduated fact locally', async () => { + await graduateFact(deps, { observation_id: 3 }); + expect(repo.listGraduated(PROJECT)).toHaveLength(1); + + const result = await ungraduateFact(deps, { observation_id: 3 }); + expect(result.removed).toBe(true); + expect(repo.listGraduated(PROJECT)).toHaveLength(0); + + const missing = await ungraduateFact(deps, { observation_id: 3 }); + expect(missing.removed).toBe(false); + }); + + it('ungraduate_fact: rejects bad observation ids', async () => { + await expect(ungraduateFact(deps, { observation_id: -1 })).rejects.toThrow( + /invalid observation_id/, + ); + }); + it('callTool: dispatches by name and rejects unknown tools', async () => { const result = (await callTool(deps, 'list_branches', { project: PROJECT })) as { project: string; diff --git a/test/repo.test.ts b/test/repo.test.ts index cba6f2c..9927328 100644 --- a/test/repo.test.ts +++ b/test/repo.test.ts @@ -327,6 +327,80 @@ describe('TreMemRepo graduated', () => { }); }); +describe('TreMemRepo removal (forget)', () => { + let tmp: string; + let repo: TreMemRepo; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-mem-repo-del-')); + const dbPath = join(tmp, 'tre-mem.db'); + migrate(dbPath); + repo = new TreMemRepo({ dbPath }); + }); + + afterEach(() => { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('deletePinById removes one pin and reports the change', () => { + const pin = repo.addPin({ + project: 'p', + branch: 'main', + observation_id: 1, + created_at_epoch: 1, + }); + expect(repo.deletePinById(pin.id)).toBe(true); + expect(repo.getPinById(pin.id)).toBeNull(); + expect(repo.deletePinById(pin.id)).toBe(false); + }); + + it('deletePinsByObservation removes every matching pin', () => { + repo.addPin({ project: 'p', branch: 'main', observation_id: 7, created_at_epoch: 1 }); + repo.addPin({ project: 'p', branch: 'main', observation_id: 7, created_at_epoch: 2 }); + repo.addPin({ project: 'p', branch: 'other', observation_id: 7, created_at_epoch: 3 }); + expect(repo.deletePinsByObservation('p', 'main', 7)).toBe(2); + expect(repo.listPinsForBranch('p', 'main')).toHaveLength(0); + expect(repo.listPinsForBranch('p', 'other')).toHaveLength(1); + }); + + it('deletePinsByContentHash removes pins with that hash', () => { + const pin = repo.addPin({ + project: 'p', + branch: 'main', + observation_id: 1, + created_at_epoch: 1, + }); + repo.markPinShared(pin.id, 'hash-abc', 100); + expect(repo.deletePinsByContentHash('hash-abc')).toBe(1); + expect(repo.getPinById(pin.id)).toBeNull(); + }); + + it('deleteGraduatedByObservation removes the fact', () => { + repo.graduateFact({ + project: 'p', + observation_id: 9, + graduated_from_branch: 'b', + graduated_at_epoch: 1, + }); + expect(repo.deleteGraduatedByObservation('p', 9)).toBe(true); + expect(repo.getGraduated('p', 9)).toBeNull(); + expect(repo.deleteGraduatedByObservation('p', 9)).toBe(false); + }); + + it('deleteGraduatedByContentHash removes facts with that hash', () => { + const g = repo.graduateFact({ + project: 'p', + observation_id: 9, + graduated_from_branch: 'b', + graduated_at_epoch: 1, + }); + repo.markGraduatedShared(g.id, 'hash-xyz', 100); + expect(repo.deleteGraduatedByContentHash('hash-xyz')).toBe(1); + expect(repo.getGraduated('p', 9)).toBeNull(); + }); +}); + describe('TreMemRepo cross-clone aliases', () => { let tmp: string; let repo: TreMemRepo; diff --git a/test/setup.test.ts b/test/setup.test.ts index 1b99f14..1bd645a 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -3,7 +3,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import { setupClaudeCode, setupTool } from '../src/setup.js'; +import { + dedupeSessionStartHooks, + detectTools, + scanSessionStartHooks, + setupClaudeCode, + setupTool, +} from '../src/setup.js'; describe('setupClaudeCode', () => { let tmp: string; @@ -76,3 +82,86 @@ describe('setupTool', () => { expect(r.message).toMatch(/claude-code/); }); }); + +describe('duplicate SessionStart hook detection', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-dup-')); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + function writeSettings( + name: string, + command: string | null, + ): { path: string; scope: 'project' | 'global' } { + const path = join(tmp, name); + const settings = command + ? { hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command }] }] } } + : { hooks: {} }; + writeFileSync(path, JSON.stringify(settings, null, 2), 'utf8'); + return { path, scope: name.includes('global') ? 'global' : 'project' }; + } + + test('scan counts a tre-mem session-start hook per file', () => { + const project = writeSettings('project.json', 'tre hook session-start'); + const global = writeSettings('global.json', '/abs/path/dist/cli.js hook session-start'); + const scans = scanSessionStartHooks(tmp, [project, global]); + expect(scans.map((s) => s.count)).toEqual([1, 1]); + }); + + test('scan ignores unrelated hooks', () => { + const project = writeSettings('project.json', 'some-other-tool run'); + const scans = scanSessionStartHooks(tmp, [project]); + expect(scans[0]?.count).toBe(0); + }); + + test('dedupe defaults to keeping the project copy (canonical, committed)', () => { + const project = writeSettings('project.json', 'tre hook session-start'); + const global = writeSettings('global.json', '/abs/dist/cli.js hook session-start'); + const result = dedupeSessionStartHooks(tmp, undefined, [project, global]); + + expect(result.kept).toBe(project.path); + expect(result.removed).toEqual([{ path: global.path, count: 1 }]); + // global leftover stripped; committed project hook untouched + expect(scanSessionStartHooks(tmp, [project, global]).map((s) => s.count)).toEqual([1, 0]); + }); + + test('dedupe can keep the global copy when asked', () => { + const project = writeSettings('project.json', 'tre hook session-start'); + const global = writeSettings('global.json', '/abs/dist/cli.js hook session-start'); + const result = dedupeSessionStartHooks(tmp, 'global', [project, global]); + + expect(result.kept).toBe(global.path); + expect(result.removed).toEqual([{ path: project.path, count: 1 }]); + expect(scanSessionStartHooks(tmp, [project, global]).map((s) => s.count)).toEqual([0, 1]); + }); + + test('detectTools marks claude-code wired from the session-start hook (no "tre-mem" literal)', () => { + mkdirSync(join(tmp, '.claude'), { recursive: true }); + writeFileSync( + join(tmp, '.claude', 'settings.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { matcher: '*', hooks: [{ type: 'command', command: 'tre hook session-start' }] }, + ], + }, + }), + 'utf8', + ); + const claude = detectTools(tmp).find((t) => t.tool === 'claude-code'); + expect(claude?.wired).toBe(true); + }); + + test('dedupe is a no-op when only one registration exists', () => { + const project = writeSettings('project.json', 'tre hook session-start'); + const global = writeSettings('global.json', null); + const result = dedupeSessionStartHooks(tmp, 'global', [project, global]); + expect(result.kept).toBe(project.path); + expect(result.removed).toEqual([]); + }); +}); diff --git a/test/sync-forget.test.ts b/test/sync-forget.test.ts new file mode 100644 index 0000000..83dbe53 --- /dev/null +++ b/test/sync-forget.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { migrate } from '../src/store/migrate.js'; +import { TreMemRepo } from '../src/store/repo.js'; +import { exportSync, type SnapshotProvider } from '../src/sync/export.js'; +import { forgetGraduated, forgetPin } from '../src/sync/forget.js'; +import { parseSyncLine } from '../src/sync/format.js'; +import { graduatedFilePath, branchFilePath } from '../src/sync/layout.js'; + +const noSnapshots: SnapshotProvider = { getSnapshots: () => new Map() }; + +function freshRepo(tmp: string, name: string): TreMemRepo { + const dbPath = join(tmp, `${name}.db`); + migrate(dbPath); + return new TreMemRepo({ dbPath }); +} + +function tombstoneLines(filePath: string): unknown[] { + if (!existsSync(filePath)) return []; + return readFileSync(filePath, 'utf8') + .split('\n') + .filter((l) => l.trim() !== '') + .map((l) => parseSyncLine(l)) + .filter((r) => r.kind === 'tombstone'); +} + +describe('forgetGraduated', () => { + let tmp: string; + let dir: string; + let repo: TreMemRepo; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-forget-')); + dir = join(tmp, '.tre-mem'); + repo = freshRepo(tmp, 'me'); + }); + + afterEach(() => { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + test('a shared graduated fact is deleted locally AND tombstoned', () => { + repo.graduateFact({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + graduated_at_epoch: 100, + }); + exportSync({ repo, snapshots: noSnapshots, project: 'p', dir, branches: [], now: 1 }); + // After export the row carries a content_hash (shared). + expect(repo.getGraduated('p', 7)?.content_hash).toMatch(/^[0-9a-f]{64}$/); + + const result = forgetGraduated({ repo, dir, project: 'p', observation_id: 7, now: 2 }); + + expect(result).toEqual({ removed: 1, tombstoned: true }); + expect(repo.getGraduated('p', 7)).toBeNull(); + const tombstones = tombstoneLines(graduatedFilePath(dir)); + expect(tombstones).toHaveLength(1); + expect(tombstones[0]).toMatchObject({ target_kind: 'graduated', observation_id: 7 }); + }); + + test('an unshared graduated fact is deleted locally with NO tombstone', () => { + repo.graduateFact({ + project: 'p', + observation_id: 9, + graduated_from_branch: 'b', + graduated_at_epoch: 100, + }); + // never exported → no .tre-mem dir, no content_hash + const result = forgetGraduated({ repo, dir, project: 'p', observation_id: 9, now: 2 }); + + expect(result).toEqual({ removed: 1, tombstoned: false }); + expect(repo.getGraduated('p', 9)).toBeNull(); + expect(existsSync(graduatedFilePath(dir))).toBe(false); + }); + + test('forgetting a missing fact is a no-op', () => { + const result = forgetGraduated({ repo, dir, project: 'p', observation_id: 404, now: 2 }); + expect(result).toEqual({ removed: 0, tombstoned: false }); + }); + + test('forgetting twice does not write a duplicate tombstone', () => { + repo.graduateFact({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + graduated_at_epoch: 100, + }); + exportSync({ repo, snapshots: noSnapshots, project: 'p', dir, branches: [], now: 1 }); + forgetGraduated({ repo, dir, project: 'p', observation_id: 7, now: 2 }); + + // Re-create + re-share the same fact, then forget again. + repo.graduateFact({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + graduated_at_epoch: 100, + }); + exportSync({ repo, snapshots: noSnapshots, project: 'p', dir, branches: [], now: 3 }); + const second = forgetGraduated({ repo, dir, project: 'p', observation_id: 7, now: 4 }); + + expect(second.removed).toBe(1); + expect(second.tombstoned).toBe(false); // identical tombstone already present + expect(tombstoneLines(graduatedFilePath(dir))).toHaveLength(1); + }); +}); + +describe('forgetPin', () => { + let tmp: string; + let dir: string; + let repo: TreMemRepo; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-forget-pin-')); + dir = join(tmp, '.tre-mem'); + repo = freshRepo(tmp, 'me'); + }); + + afterEach(() => { + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + test('a shared pin is deleted locally AND tombstoned', () => { + repo.addPin({ + project: 'p', + branch: 'main', + observation_id: 5, + note: 'temp', + created_at_epoch: 10, + }); + exportSync({ repo, snapshots: noSnapshots, project: 'p', dir, branches: ['main'], now: 1 }); + + const result = forgetPin({ + repo, + dir, + project: 'p', + branch: 'main', + observation_id: 5, + now: 2, + }); + + expect(result).toEqual({ removed: 1, tombstoned: true }); + expect(repo.listPinsForBranch('p', 'main')).toHaveLength(0); + expect(tombstoneLines(branchFilePath(dir, 'main'))).toHaveLength(1); + }); + + test('an unshared pin is deleted locally with NO tombstone', () => { + repo.addPin({ + project: 'p', + branch: 'main', + observation_id: 6, + note: 'temp', + created_at_epoch: 10, + }); + const result = forgetPin({ + repo, + dir, + project: 'p', + branch: 'main', + observation_id: 6, + now: 2, + }); + expect(result).toEqual({ removed: 1, tombstoned: false }); + expect(repo.listPinsForBranch('p', 'main')).toHaveLength(0); + }); +}); diff --git a/test/sync-format.test.ts b/test/sync-format.test.ts index f0d6af8..f8dcc06 100644 --- a/test/sync-format.test.ts +++ b/test/sync-format.test.ts @@ -8,6 +8,7 @@ import { serializeSyncRecord, type GraduatedRecord, type PinRecord, + type TombstoneRecord, } from '../src/sync/format.js'; function samplePin(overrides: Partial = {}): PinRecord { @@ -160,3 +161,60 @@ describe('serialize / parse round-trip', () => { expect(() => parseSyncLine(line)).toThrow(); }); }); + +describe('tombstone records', () => { + function sampleTombstone(overrides: Partial = {}): TombstoneRecord { + return { + schema: SYNC_SCHEMA_VERSION, + kind: 'tombstone', + content_hash: 'a'.repeat(64), + target_kind: 'graduated', + project: 'tre-mem', + branch: null, + observation_id: 99, + author: 'alice', + tombstoned_at_epoch: 1_780_001_000, + ...overrides, + }; + } + + test('graduated tombstone round-trips through JSONL', () => { + const t = sampleTombstone(); + const line = serializeSyncRecord(t); + expect(line).not.toContain('\n'); + expect(parseSyncLine(line)).toEqual(t); + }); + + test('pin tombstone round-trips with branch set', () => { + const t = sampleTombstone({ target_kind: 'pin', branch: 'feature/payment' }); + expect(parseSyncLine(serializeSyncRecord(t))).toEqual(t); + }); + + test('serialized tombstone keys are in a stable order', () => { + const line = serializeSyncRecord(sampleTombstone()); + expect(line.indexOf('"schema"')).toBeLessThan(line.indexOf('"kind"')); + expect(line.indexOf('"content_hash"')).toBeLessThan(line.indexOf('"target_kind"')); + }); + + test('parse rejects tombstone with invalid target_kind', () => { + const line = JSON.stringify({ + schema: 1, + kind: 'tombstone', + content_hash: 'x', + target_kind: 'bogus', + project: 'p', + branch: null, + observation_id: null, + author: null, + tombstoned_at_epoch: 1, + }); + expect(() => parseSyncLine(line)).toThrow(/target_kind/); + }); + + test('back-compat: an unknown future kind still throws (caller skips the line)', () => { + // Old clients treat tombstone as unknown; new clients treat genuinely + // unknown kinds the same way — error-skip, never crash the import. + const line = JSON.stringify({ schema: 1, kind: 'future-thing', content_hash: 'x' }); + expect(() => parseSyncLine(line)).toThrow(/kind/); + }); +}); diff --git a/test/sync-import.test.ts b/test/sync-import.test.ts index 84c72e4..640760e 100644 --- a/test/sync-import.test.ts +++ b/test/sync-import.test.ts @@ -6,8 +6,14 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { migrate } from '../src/store/migrate.js'; import { TreMemRepo } from '../src/store/repo.js'; import { exportSync, type SnapshotProvider } from '../src/sync/export.js'; +import { + graduatedContentHash, + pinContentHash, + serializeSyncRecord, + type TombstoneRecord, +} from '../src/sync/format.js'; import { importDir } from '../src/sync/import.js'; -import { branchFilePath } from '../src/sync/layout.js'; +import { branchFilePath, graduatedFilePath } from '../src/sync/layout.js'; const noSnapshots: SnapshotProvider = { getSnapshots: () => new Map() }; @@ -236,7 +242,173 @@ describe('importDir', () => { const bob = freshRepo(tmp, 'bob'); try { const result = importDir({ repo: bob, dir: join(tmp, 'nope'), now: 1 }); - expect(result).toMatchObject({ pins: 0, graduated: 0, files: [] }); + expect(result).toMatchObject({ pins: 0, graduated: 0, tombstoned: 0, files: [] }); + } finally { + bob.close(); + } + }); + + function gradTombstone(hash: string): TombstoneRecord { + return { + schema: 1, + kind: 'tombstone', + content_hash: hash, + target_kind: 'graduated', + project: 'p', + branch: null, + observation_id: 7, + author: 'alice', + tombstoned_at_epoch: 999, + }; + } + + test('a tombstone removes a previously-imported graduated fact on re-import', () => { + const alice = freshRepo(tmp, 'alice'); + alice.graduateFact({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + graduated_at_epoch: 200, + }); + exportSync({ repo: alice, snapshots: noSnapshots, project: 'p', dir, branches: [], now: 1 }); + alice.close(); + + const bob = freshRepo(tmp, 'bob'); + try { + importDir({ repo: bob, dir, now: 2 }); + expect(bob.listGraduated('p')).toHaveLength(1); + + // Alice forgets the fact: append a tombstone for its content_hash. + const hash = graduatedContentHash({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + title: null, + body: null, + }); + appendFileSync( + graduatedFilePath(dir), + serializeSyncRecord(gradTombstone(hash)) + '\n', + 'utf8', + ); + + const result = importDir({ repo: bob, dir, now: 3 }); + expect(result.tombstoned).toBe(1); + expect(bob.listGraduated('p')).toHaveLength(0); + } finally { + bob.close(); + } + }); + + test('tombstone + original in the same fresh file resolves to removed (no resurrect)', () => { + const alice = freshRepo(tmp, 'alice'); + alice.graduateFact({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + graduated_at_epoch: 200, + }); + exportSync({ repo: alice, snapshots: noSnapshots, project: 'p', dir, branches: [], now: 1 }); + alice.close(); + const hash = graduatedContentHash({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + title: null, + body: null, + }); + appendFileSync(graduatedFilePath(dir), serializeSyncRecord(gradTombstone(hash)) + '\n', 'utf8'); + + const bob = freshRepo(tmp, 'bob'); + try { + const result = importDir({ repo: bob, dir, now: 2 }); + expect(bob.listGraduated('p')).toHaveLength(0); + const gradFile = result.files.find((f) => f.file.endsWith('graduated.jsonl')); + expect(gradFile?.inserted).toBe(0); // insert skipped, not inserted-then-deleted + } finally { + bob.close(); + } + }); + + test('removing a tombstoned fact is idempotent across re-imports', () => { + const alice = freshRepo(tmp, 'alice'); + alice.graduateFact({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + graduated_at_epoch: 200, + }); + exportSync({ repo: alice, snapshots: noSnapshots, project: 'p', dir, branches: [], now: 1 }); + alice.close(); + const hash = graduatedContentHash({ + project: 'p', + observation_id: 7, + graduated_from_branch: 'feature/x', + title: null, + body: null, + }); + appendFileSync(graduatedFilePath(dir), serializeSyncRecord(gradTombstone(hash)) + '\n', 'utf8'); + + const bob = freshRepo(tmp, 'bob'); + try { + importDir({ repo: bob, dir, now: 2 }); + const again = importDir({ repo: bob, dir, now: 3, force: true }); + const gradFile = again.files.find((f) => f.file.endsWith('graduated.jsonl')); + expect(gradFile?.tombstoned).toBe(0); // already gone + expect(bob.listGraduated('p')).toHaveLength(0); + } finally { + bob.close(); + } + }); + + test('a pin tombstone removes a previously-imported pin on re-import', () => { + const alice = freshRepo(tmp, 'alice'); + alice.addPin({ + project: 'p', + branch: 'main', + observation_id: 5, + note: 'temp decision', + created_at_epoch: 10, + }); + exportSync({ + repo: alice, + snapshots: noSnapshots, + project: 'p', + dir, + branches: ['main'], + now: 1, + }); + alice.close(); + + const bob = freshRepo(tmp, 'bob'); + try { + importDir({ repo: bob, dir, now: 2 }); + expect(bob.listPinsForBranch('p', 'main')).toHaveLength(1); + + const hash = pinContentHash({ + project: 'p', + branch: 'main', + observation_id: 5, + note: 'temp decision', + title: null, + body: null, + }); + const t: TombstoneRecord = { + schema: 1, + kind: 'tombstone', + content_hash: hash, + target_kind: 'pin', + project: 'p', + branch: 'main', + observation_id: 5, + author: 'alice', + tombstoned_at_epoch: 999, + }; + appendFileSync(branchFilePath(dir, 'main'), serializeSyncRecord(t) + '\n', 'utf8'); + + const result = importDir({ repo: bob, dir, now: 3 }); + expect(result.tombstoned).toBe(1); + expect(bob.listPinsForBranch('p', 'main')).toHaveLength(0); } finally { bob.close(); }