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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to agent-memory are documented in this file. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.1.2 - 2026-09-17

### Added

- `status --json` prints the readout as one object, with an `ok` flag and a `prerequisites` block ([status](docs/commands.md#status)).

### Fixed

- `status` and `doctor` exit non-zero with a reason code when a prerequisite such as git fails ([prerequisites](docs/commands.md#prerequisites)).
- `doctor` and `status --fetch` name a sandbox-blocked credential helper, not an unreachable remote ([doctor](docs/commands.md#doctor)).

## 0.1.1 - 2026-09-12

### Added
Expand Down
41 changes: 37 additions & 4 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,39 @@ is `<home>/projects/<name>/workspace.json`), then `~/agent-memory`.
`status` prints where the home resolves and where its sync stands: the rule
that chose the home, the marker, the allowlist, the tiers, then the tree and
the upstream. It contacts the remote only with `--fetch`, and it exits 1 when
the tree is dirty or diverged (ahead and behind are reported, not failed).
the tree is dirty or diverged (ahead and behind are reported, not failed) or
when a prerequisite failed.

`status --json` prints the same readout as one object, with the same exit
code:

| Key | Holds |
| --- | --- |
| `ok` | `true` exactly when the exit code is 0 |
| `home`, `source`, `project`, `exists` | the resolved path, the rule that chose it, the bound project, whether the path is a directory |
| `layout` | `marker`, `gitignore`, `org_memory`, `projects`; `null` when the home does not exist |
| `sync` | `configured`, `repository`, `enclosing`, `state`, `state_text`, `remote`, `upstream`, `fetched`, `ahead`, `behind`, `dirty`, `diverged`; `null` when the home does not exist, and the fields git reports are `null` when git was not read |
| `prerequisites` | `ok`, `reason`, `message`, `remedy`, `git` (`present`, `version`), `credential_helper` (`checked`, `blocked`) |

`sync.state` is one of `synced`, `ahead`, `behind`, `diverged`,
`no_upstream`, `local_only` or `fetch_failed`; `state_text` is the phrase the
table prints.

### Prerequisites

`status` and `doctor` share three prerequisite failures. Each exits
non-zero, puts its code in `prerequisites.reason`, and prints the same
message and remedy in the table:

| `reason` | When |
| --- | --- |
| `home_unresolved` | The resolved home is not a directory (exit 1), or the resolution itself failed, e.g. on a broken binding (exit 2, as on every verb; `--json` still prints the readout) |
| `git_missing` | Git does not run, and the home carries the sync marker. Without the marker, `git.present` is reported but nothing fails |
| `credential_helper_blocked` | A fetch failed because a sandbox blocked the git credential helper. The helper runs only when git has no cached credential for the remote, so after an unsandboxed fetch or push the same sandboxed run passes. Judged only when a fetch ran: always for `doctor`, with `--fetch` for `status` |

On a blocked helper, the sync row names the cause instead of repeating the
helper's output, which can name the secret it was after. Other fetch failures
still print git's output, which is their only diagnostic.

## Doctor

Expand All @@ -23,9 +55,10 @@ a record, and a traversal it cannot finish is an error row, never a pass) and
the sync repository (marker, allowlist, tracked and untracked memory files,
the tree with memory changes told apart from changes outside the allowlist,
upstream, remote, last-commit age, per-instance state, overlay ignores).
It reads no memory content, repairs nothing, exits 1 only on an error row,
and points at `memory-lint` when that is installed; `--json` emits the same
rows as data.
It reads no memory content, repairs nothing, exits 1 on an error row or a
failed [prerequisite](#prerequisites), and points at `memory-lint` when that
is installed; `--json` emits the same rows as data, followed by the
`prerequisites` block that `status --json` carries.

## Init

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling>=1.27"]
requires = ["hatchling>=1.32,<2"]
build-backend = "hatchling.build"

[project]
Expand Down
2 changes: 1 addition & 1 deletion src/agent_memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

from .home import HomeError, HomeResolution, resolve_home

__version__ = "0.1.1"
__version__ = "0.1.2"

__all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
44 changes: 33 additions & 11 deletions src/agent_memory/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path
from typing import Callable, List, Optional, Sequence, Tuple

from . import __version__, archive, debrief, doctor, events, org, setup, startup, status, sync, workflow
from . import __version__, archive, debrief, doctor, events, org, prerequisites, setup, startup, status, sync, workflow
from .home import BINDING_FILE, HomeError, HomeResolution, find_project, resolve_home

EXIT_OK = 0
Expand Down Expand Up @@ -51,16 +51,17 @@ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str,
"status",
_status,
"Show which home resolves, which rule chose it, whether the layout is in place, and where the sync stands. "
"Exit 1 when the tree is dirty or diverged.",
"Exit 1 when the tree is dirty or diverged, or a prerequisite (git, the home, the credential helper) failed.",
)
status_.add_argument(
"--fetch", action="store_true", help="contact the remote before counting ahead/behind (no network otherwise)"
)
status_.add_argument("--json", dest="json_output", action="store_true", help="emit the readout as JSON")
doctor_ = add(
"doctor",
_doctor,
"Check the home's setup and health: the debrief store's layout and the sync repository. "
"Reads no memory content; repairs nothing. Exit 1 on an error row.",
"Reads no memory content; repairs nothing. Exit 1 on an error row or a failed prerequisite.",
)
doctor_.add_argument("--json", dest="json_output", action="store_true", help="emit the report as JSON")
init_ = add(
Expand Down Expand Up @@ -235,23 +236,44 @@ def _max_chars(value: str) -> int:


def _status(args: argparse.Namespace) -> int:
readout = status.inspect(resolve_home(args.home), fetch=args.fetch)
print("\n".join(readout.lines()))
try:
resolution = resolve_home(args.home)
except HomeError as exc:
if not args.json_output:
raise
print(json.dumps(status.unresolved(exc), indent=2))
return EXIT_USAGE
readout = status.inspect(resolution, fetch=args.fetch)
if args.json_output:
print(json.dumps(readout.to_json(), indent=2))
else:
print("\n".join(readout.lines()))
return readout.exit_code


def _doctor(args: argparse.Namespace) -> int:
home = resolve_home(args.home).path
memory_lint = doctor.find_memory_lint()
try:
home = resolve_home(args.home).path
except HomeError as exc:
if not args.json_output:
raise
unresolved = prerequisites.home_error(exc)
print(json.dumps(doctor.to_json([], memory_lint=memory_lint, prerequisites=unresolved), indent=2))
return EXIT_USAGE
if not home.is_dir():
print(f"agent-memory: error: {home} is not a directory", file=sys.stderr)
missing = prerequisites.home_missing(home)
if args.json_output:
print(json.dumps(doctor.to_json([], memory_lint=memory_lint, prerequisites=missing), indent=2))
elif missing.failure:
print(f"agent-memory: error: {missing.failure.line()}", file=sys.stderr)
return EXIT_FAILED
categories = doctor.run_doctor(home)
memory_lint = doctor.find_memory_lint()
categories, readout = doctor.diagnose(home)
if args.json_output:
print(json.dumps(doctor.to_json(categories, memory_lint=memory_lint), indent=2))
print(json.dumps(doctor.to_json(categories, memory_lint=memory_lint, prerequisites=readout), indent=2))
else:
sys.stdout.write(doctor.report(categories, memory_lint=memory_lint))
return EXIT_FAILED if doctor.has_errors(categories) else EXIT_OK
return EXIT_FAILED if doctor.has_errors(categories) or not readout.ok else EXIT_OK


def _init(args: argparse.Namespace) -> int:
Expand Down
78 changes: 68 additions & 10 deletions src/agent_memory/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple

from . import layout, sync
from . import layout, prerequisites, sync
from .git_runner import GitResult, GitRunner, run_git
from .prerequisites import Prerequisites
from .sync import GitState

#: A last commit older than this many days is reported stale.
Expand Down Expand Up @@ -109,7 +110,15 @@ def run_doctor(
home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None
) -> List[Category]:
"""Both memory categories for ``home``, in report order."""
return [check_org_memory(home), check_memory_sync(home, runner=runner, now=now)]
return diagnose(home, runner=runner, now=now)[0]


def diagnose(
home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None
) -> Tuple[List[Category], Prerequisites]:
"""Both memory categories in report order, and the prerequisite readout the sync checks took."""
memory_sync, readout = _memory_sync(home, runner=runner, now=now)
return [check_org_memory(home), memory_sync], readout


def find_memory_lint(which: Callable[[str], Optional[str]] = shutil.which) -> Optional[str]:
Expand All @@ -135,10 +144,16 @@ def report(categories: Sequence[Category], *, memory_lint: Optional[str] = None)
return "\n".join(lines) + "\n"


def to_json(categories: Sequence[Category], *, memory_lint: Optional[str] = None) -> Dict[str, Any]:
"""The report as data, in the same order as the text."""
def to_json(
categories: Sequence[Category],
*,
memory_lint: Optional[str] = None,
prerequisites: Optional[Prerequisites] = None,
) -> Dict[str, Any]:
"""The report as data, in the same order as the text; ``prerequisites`` goes last, so the
rows before it read as they did without it."""
output: Dict[str, Any] = {
"has_errors": has_errors(categories),
"has_errors": has_errors(categories) or (prerequisites is not None and not prerequisites.ok),
"memory_lint": memory_lint,
"categories": [],
}
Expand All @@ -152,6 +167,8 @@ def to_json(categories: Sequence[Category], *, memory_lint: Optional[str] = None
output["categories"].append(
{"name": category.name, "worst_severity": category.worst_severity.value, "results": rows}
)
if prerequisites is not None:
output["prerequisites"] = prerequisites.to_json()
return output


Expand Down Expand Up @@ -325,32 +342,48 @@ def check_memory_sync(
home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None
) -> Category:
"""The sync setup and the repository's state, through git alone; nothing is changed."""
return _memory_sync(home, runner=runner, now=now)[0]


def _memory_sync(
home: Path, *, runner: Optional[GitRunner], now: Optional[dt.datetime]
) -> Tuple[Category, Prerequisites]:
cat = Category("Memory Sync")
marker = layout.MARKER_FILE
git = prerequisites.probe_git(runner)

try:
configured = _is_file(sync.marker_path(home))
except OSError as exc:
cat.add("memory-marker", Severity.warn, f"{marker} — {_not_inspected(exc)}")
return cat
return cat, prerequisites.assess(git, configured=False)
if not configured:
cat.add(
"memory-marker",
Severity.skip,
f"{marker} — not configured; memory sync hooks are disabled",
"Run: agent-memory enable [--remote URL]",
)
return cat
return cat, prerequisites.assess(git, configured=False)
cat.add("memory-marker", Severity.ok, f"{marker} — present")

if not git.present:
# Without git every readout below fails, and "not a git repository" would be the wrong finding.
cat.add(
"memory-git",
Severity.error,
f"{marker} — present, but {prerequisites.GIT_MISSING_TEXT}",
prerequisites.GIT_MISSING_REMEDY,
)
return cat, prerequisites.assess(git, configured=True)
if not sync.is_git_repo(home, runner):
cat.add(
"memory-git",
Severity.warn,
f"{marker} — present, but {home} is not a git repository",
"Run `agent-memory enable`, or `agent-memory disable` to remove the marker",
)
return cat
return cat, prerequisites.assess(git, configured=True)
enclosing = enclosing_repository(home, runner)
if enclosing is not None:
# The sync verbs refuse this home; reading the enclosing repository's state as the home's would be wrong.
Expand All @@ -361,7 +394,7 @@ def check_memory_sync(
"a memory home must be the root of its own repository",
"Move the home out of the enclosing repository, or `agent-memory disable` to remove the marker",
)
return cat
return cat, prerequisites.assess(git, configured=True)

root_gitignore = home / layout.GITIGNORE_FILE
try:
Expand Down Expand Up @@ -450,6 +483,10 @@ def check_memory_sync(
if not state.has_remote:
cat.add("sync-state", Severity.ok, text)
cat.add("remote", Severity.skip, "remote — skipped; local-only memory repo")
elif prerequisites.helper_blocked(state):
# Not the network and not the remote's permissions: git never had credentials to try with.
cat.add("sync-state", Severity.error, text, prerequisites.HELPER_BLOCKED_REMEDY)
cat.add("remote", Severity.skip, "remote — not checked; the sandbox blocked the git credential helper")
elif state.fetch_failed:
cat.add("sync-state", Severity.warn, text, "Check network access and remote permissions")
cat.add("remote", Severity.warn, "remote — not reachable", "Check network access and remote permissions")
Expand Down Expand Up @@ -523,7 +560,8 @@ def check_memory_sync(
else:
cat.add("memory-overlays", Severity.ok, f"memory .gitignore overlays — {len(overlays)} safe")

return cat
fetched = state is not None and state.has_remote
return cat, prerequisites.assess(git, configured=True, state=state, fetched=fetched)


def _add_working_tree(cat: Category, home: Path, runner: Optional[GitRunner], state: GitState) -> None:
Expand Down Expand Up @@ -568,10 +606,30 @@ def enclosing_repository(home: Path, runner: Optional[GitRunner] = None) -> Opti
return root


def sync_state(state: GitState) -> str:
"""Where the repository stands, as the code ``status --json`` carries beside :func:`sync_state_text`."""
if not state.has_remote:
return "local_only"
if state.fetch_failed:
return "fetch_failed"
if not state.has_upstream:
return "no_upstream"
if state.diverged:
return "diverged"
if state.behind:
return "behind"
if state.ahead:
return "ahead"
return "synced"


def sync_state_text(state: GitState) -> str:
"""One phrase for where the repository stands; ``status`` and ``doctor`` share it."""
if not state.has_remote:
return "local-only; no remote configured"
if prerequisites.helper_blocked(state):
# The helper's own output names the secret it was after; the phrase names the cause instead.
return f"remote fetch failed: {prerequisites.HELPER_BLOCKED_TEXT}"
if state.fetch_failed:
return f"remote fetch failed: {state.fetch_output}"
if not state.has_upstream:
Expand Down
Loading