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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ secrets.json
.claude/settings.json
.claude/settings.local.json
.claude/revive-counter
.claude/scheduled_tasks.lock

# Local revive brief (cites private ADRs / PLAN.md per project's own
# private-data rule — keep out of the public repo until scrubbed)
Expand Down
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Changelog

All notable changes to armillary are tracked here. The format is loosely
based on [Keep a Changelog](https://keepachangelog.com/) and the project
follows pre-release alpha rules — backwards-compat shims are intentionally
skipped while we are still in 0.x.

## Unreleased

### Added

- **Revive with receipts** — new MCP tool `armillary_revive(project_path)`
that returns the vanilla `revive show` brief plus a "STEAL_HITS" section
with up to three quoted code blocks from your other repositories. The
killer use case: when an agent resumes work on a project, it can quote
code you already wrote elsewhere instead of rebuilding it from scratch.
- Why it ships: vanilla `revive` answers "what was I doing?" — but every
forgotten side project also forgets the code patterns that already
solved its problems. Pulling matching blocks from your other repos
turns the brief from "remind me" into "here's the function you wrote
last January for the same thing".
- How it works: the tool composes the existing `revive_service.revive_show()`
output with `steal_service.steal()` matches. Query strategy v0.1 is
the project name only — empirically the simplest signal that
delivers cross-repo matches. An earlier draft also folded in the
last commit subject, but a 6-token AND-FTS query is precision-extreme
and produced zero matches across a real, sizeable cache.
Single-token ranking lets underscore/dash names tokenise naturally
(`pdf_to_quiz`, `reddit_promo_planner`) and BM25 picks the best
matches. Sanity check on three real projects returned 1+ relevant
cross-repo block each.
- Scope decision: only `STEAL_HITS` is in v0.1. Other candidate fields
(project status, last-touched timestamp, journal entries) were
deferred — status carries a real risk of eroding trust in the whole
brief when stale, last-touched is something an agent can read from
`git log` itself, and journal entries add noise without a proven win.
A 30-day defeat test gates whether to add them in v0.2.
- Surface: MCP-only for now (visibility rule satisfied). A dashboard
surface for the enhanced brief is conditional on the defeat test
passing in v0.2.

### Changed

- README.md — added a one-line note pointing at the new MCP tool from the
Revive feature bullet, so MCP-using readers see both the standalone and
enhanced paths.

### Tests

- New unit + integration coverage for `generate_enhanced_brief`: happy
path, empty steal results, `ReviveError` propagation,
project-not-in-cache fallback, top-N capping, no-symbol placeholder,
`~` expansion, own-repo filtering, repo-prefix collision handling,
graceful degradation when `steal()` raises, plus three MCP-wrapper
cases and two end-to-end tests that use a real `Cache` + real
`CodeIndex` (only the `revive` CLI subprocess is stubbed).

## 2026-04 — recent module split

Refactored three modules above the 400-line target into focused siblings,
keeping public import paths stable:

- `metadata.py` (521 → 110) split into `metadata.py` + `metadata_git.py`
+ `metadata_readme.py` + `metadata_files.py` (PR #34)
- `cache.py` (506 → 393) extracted row mapping into `cache_mapping.py`
(PR #35)
- `mcp_server.py` (486 → 75) split into `mcp_server.py` + `mcp_helpers.py`
+ `mcp_tools.py` + `mcp_instance.py` (PR #36)

CLAUDE.md tech-debt note updated accordingly.

## 2026-04 — Revive integration (PR #33)

First-class `context-revive` integration: dashboard scaffold + copy-prompt
actions on the project detail page, brief age inline, paired Copy/Launch
buttons. Replaced the earlier headless-runner experiment because the
copy-prompt UX gives users full control over `claude` invocations and
matches `revive`'s "fresh agent session" requirement for the audit pass.
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Your AI coding agent (Claude Code, Cursor) gets the same data automatically via
- **Weekly pulse** — what changed, what went dormant, what's waiting (`pulse`)
- **Activity heatmap** — 12-month contribution view, exportable as a shareable HTML card (`card`)
- **Searches** across ALL projects with ripgrep
- **Revive** — keeps AI agents oriented in long Claude Code sessions via `context-revive` briefs, with scaffold + copy-prompt actions in the dashboard detail page (requires the `revive` CLI on PATH)
- **Revive** — keeps AI agents oriented in long Claude Code sessions via `context-revive` briefs, with scaffold + copy-prompt actions in the dashboard detail page; `armillary_revive` MCP tool also pulls up to three quoted code blocks from your other repos so the agent can reuse what you've already written (requires the `revive` CLI on PATH)
- **MCP server** — your AI agent knows your full project history
- **Launches** projects into Cursor, VS Code, Zed, Claude Code, terminal, Finder

Expand Down Expand Up @@ -141,7 +141,7 @@ armillary start

## MCP server for AI coding agents

armillary exposes five MCP tools that Claude Code / Cursor can call:
armillary exposes seven MCP tools that Claude Code / Cursor can call:

| Tool | What it does | Speed |
|---|---|---|
Expand All @@ -150,6 +150,8 @@ armillary exposes five MCP tools that Claude Code / Cursor can call:
| `armillary_search` | Exact code search: function names, imports, error messages | <10ms |
| `armillary_projects` | List all projects with path, status, description | instant |
| `armillary_pulse` | What changed in my portfolio this week? | instant |
| `armillary_steal` | Reusable 40-line blocks ranked across all your repos | <100ms |
| `armillary_revive` | Project brief plus up to 3 quoted blocks from other repos | sub-second |

`armillary config --init` auto-configures MCP in `~/.claude/mcp.json`. Or manually:

Expand All @@ -167,6 +169,58 @@ armillary exposes five MCP tools that Claude Code / Cursor can call:
For details on the transport, lifecycle, cache-staleness semantics, and
how to debug tool calls, see [`docs/mcp.md`](docs/mcp.md).

### `armillary_revive` — revive with receipts

Vanilla `revive show` (from the `context-revive` CLI) tells the agent
what the project is. `armillary_revive` adds the second half of the
question: *what did I already write that solves the same thing
elsewhere?*

**What it returns:** the vanilla revive brief verbatim, followed by a
`STEAL_HITS` markdown section with up to three quoted code blocks
ranked across your *other* indexed repositories. Each block carries
its source project, file path, line range, and detected symbol. When
no other repo matches the project name, the section is omitted
entirely — the brief comes back unchanged.

```text
<vanilla revive show output>

## STEAL_HITS — code you wrote in other repos

- other-project/src/handler.py:42-81 — handle_event
```py
def handle_event(payload):
...
```
```

**When to call it:** at the start of a session in a project the agent
hasn't seen before, especially after a long break. The cross-repo
quotes are most valuable for projects whose name token shows up in
sibling repos (test files referencing it, integration specs, a fork,
documentation that names it). For tool-style projects whose name
appears nowhere else, the section will be empty — that's expected,
not a bug.

**Requirements:**

- The `revive` CLI on `PATH` (`pipx install context-revive`).
- A `.revive/static.md` in the target project — bootstrap once with
`revive init`, then fill it via the suggest prompt from the dashboard.
- An armillary scan that has populated the code index
(`armillary scan` or the dashboard "Scan now" button) — without it,
`STEAL_HITS` falls back to brief-only.

**Failure modes (graceful):**

- `revive` binary missing → returns a one-line `revive failed: …`
string instead of crashing the agent's tool call.
- Code index missing or built without FTS5 → returns the vanilla
brief with no `STEAL_HITS` section.
- Output over the shared MCP response budget → trimmed with a
`[truncated to fit response budget]` marker.

## Privacy

`armillary` **never sends data off-device**. Project index, metadata, cache, and config all live on your local disk.
Expand All @@ -179,7 +233,7 @@ how to debug tool calls, see [`docs/mcp.md`](docs/mcp.md).
```bash
uv sync --extra dev

# 440+ tests covering scanner / metadata / status / cache / config /
# 450+ tests covering scanner / metadata / status / cache / config /
# launcher / search / exporter / bootstrap / CLI / MCP / next / context /
# pulse / share / heatmap / transitions / purpose / revenue / revive
.venv/bin/python -m pytest
Expand Down
6 changes: 5 additions & 1 deletion src/armillary/mcp_server.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""MCP server entrypoint for `armillary mcp-serve`.

Six tools — see ``mcp_tools`` for implementations:
Seven tools — see ``mcp_tools`` for implementations:

- ``armillary_next`` — what should I work on today? (momentum/zombie/gold)
- ``armillary_search`` — ripgrep literal search across all indexed repos
- ``armillary_projects`` — list all indexed projects with metadata
- ``armillary_context`` — where was I? project state for re-entry
- ``armillary_steal`` — find reusable 40-line blocks from prior repos
- ``armillary_pulse`` — weekly pulse over the portfolio
- ``armillary_revive`` — project brief plus up to 3 quoted blocks
from other repos

This module is the public seam: it owns ``run_server`` and re-exports
the helpers + tool callables that ``tests/test_mcp_server.py`` imports
Expand Down Expand Up @@ -42,6 +44,7 @@
armillary_next,
armillary_projects,
armillary_pulse,
armillary_revive,
armillary_search,
armillary_steal,
)
Expand All @@ -54,6 +57,7 @@
"armillary_next",
"armillary_projects",
"armillary_pulse",
"armillary_revive",
"armillary_search",
"armillary_steal",
# Helpers re-exported for tests.
Expand Down
29 changes: 29 additions & 0 deletions src/armillary/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
from __future__ import annotations

import json
from pathlib import Path

from armillary.cache import Cache
from armillary.exclude_service import filter_excluded
from armillary.mcp_helpers import (
_RESPONSE_MAX_CHARS,
_clamp_max_results,
_get_project_roots,
_hit_to_dict,
Expand All @@ -21,6 +23,8 @@
_safe_search_json,
)
from armillary.mcp_instance import mcp
from armillary.revive_enhanced import generate_enhanced_brief
from armillary.revive_service import ReviveError
from armillary.search import LiteralSearch
from armillary.status_override import filter_archived
from armillary.status_override import get_override as get_override_fn
Expand Down Expand Up @@ -358,3 +362,28 @@ def armillary_context(project_name: str) -> str:
result["monthly_revenue_usd"] = rev

return json.dumps(result, separators=(",", ":"), default=str)


@mcp.tool()
def armillary_revive(project_path: str) -> str:
"""Call this before resuming work in a project that has a revive brief.

Returns the markdown brief plus up to 3 quoted code blocks from
your other indexed repositories that match the project name.

Example: armillary_revive("/path/to/project") → markdown brief
plus matches.
"""
try:
output = generate_enhanced_brief(Path(project_path))
except ReviveError as exc:
return f"revive failed: {exc}"
except Exception as exc: # noqa: BLE001 — defensive at MCP boundary
return f"armillary_revive failed: {exc}"
Comment on lines +367 to +382
if len(output) <= _RESPONSE_MAX_CHARS:
return output
# Match the safety budget the other MCP tools enforce so a huge brief
# or a code block stuffed with content cannot blow up the transport.
truncated_marker = "\n\n[truncated to fit response budget]"
keep = _RESPONSE_MAX_CHARS - len(truncated_marker)
return output[:keep] + truncated_marker
118 changes: 118 additions & 0 deletions src/armillary/revive_enhanced.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Compose revive output with matching code from other repositories.

Query strategy v0.1: the project name is the only signal we send to
``steal()``. Empirically this gives 5–8 cross-repo matches for typical
underscore / dash naming (``pdf_to_quiz``, ``reddit_promo_planner``,
``claude-code-project-boundary``) because FTS5 tokenises the separators
into meaningful sub-tokens. An earlier draft also folded in the last
commit subject, but a 6-token AND query returns zero hits in practice.
Single-token ranking is the simplest thing that delivers real value.
"""

from __future__ import annotations

from pathlib import Path

from armillary.cache import Cache
from armillary.revive_service import revive_show
from armillary.steal_service import steal


def generate_enhanced_brief(
project_path: Path,
*,
steal_limit: int = 3,
timeout: float = 5.0,
) -> str:
"""Compose vanilla `revive show` brief plus matching code blocks.

Returns the revive brief as markdown. When matches are found, appends
a STEAL_HITS section with up to ``steal_limit`` quoted blocks from
other repositories. Propagates ReviveError from ``revive_show``.
"""
project_path = project_path.expanduser()
brief = revive_show(project_path, timeout=timeout)
query = _project_name(project_path)
if not query:
return brief

# Overfetch and filter out hits from the project being revived — the
# tool promises quotes from OTHER repos. Without this, a project that
# has indexed itself can crowd out real cross-repo matches.
own_repo = _resolve(project_path)
try:
raw = steal(query, limit=steal_limit * 3)
except Exception: # noqa: BLE001 — graceful: enhanced is bonus over vanilla
# Steal can raise on missing/corrupt code_index.db or a SQLite
# build without FTS5. The vanilla brief is still useful on its
# own, so swallow the failure and fall back to brief-only.
return brief

results = [r for r in raw if _resolve(Path(r.block.repo_path)) != own_repo][
:steal_limit
]
if not results:
return brief

entries = []
for result in results:
block = result.block
symbol = block.symbol or "(no symbol)"
entries.append(
Comment on lines +57 to +61
"\n".join(
[
f"- {result.project_name}/{_display_path(block)}:"
f"{block.start_line}-{block.end_line} — {symbol}",
f"```{block.language_ext}",
block.content,
"```",
]
)
)

rendered_entries = "\n\n".join(entries)
return (
f"{brief}\n\n"
"## STEAL_HITS — code you wrote in other repos\n\n"
f"{rendered_entries}"
)


def _display_path(block: object) -> str:
"""Render ``block.path`` as a short repo-relative string.

The scanner stores ``CodeBlock.path`` as an absolute path; left raw
that produces redundant output like ``invoicer//Users/.../invoicer/src/x``.
Strip the ``repo_path`` prefix when present so the bullet line reads
``invoicer/src/x``. Use ``Path.relative_to`` (not raw string
``startswith``) to avoid false-positive prefix collisions like
``/repos/app`` vs ``/repos/app2/src/x.py``. Fall back to the
absolute path on any structural surprise so we never lose the
file pointer.
"""
block_path = str(getattr(block, "path", ""))
repo_path = str(getattr(block, "repo_path", ""))
if not repo_path:
return block_path
try:
rel = Path(block_path).relative_to(repo_path)
except ValueError:
return block_path
return str(rel)


def _resolve(path: Path) -> str:
"""Resolve a path, falling back to the literal string when stat fails."""
try:
return str(path.resolve())
except OSError:
return str(path)


def _project_name(project_path: Path) -> str:
"""Return the cached project name, or the final path part."""
with Cache() as cache:
project = cache.get_project(project_path)
if project is not None:
return project.name
return project_path.name
Loading
Loading