agent-memory-cli 0.1.1__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/CHANGELOG.md +17 -0
  2. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/PKG-INFO +7 -6
  3. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/README.md +6 -5
  4. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/pyproject.toml +1 -1
  5. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/__init__.py +1 -1
  6. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/cli.py +36 -14
  7. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/doctor.py +68 -10
  8. agent_memory_cli-0.1.3/src/agent_memory/prerequisites.py +151 -0
  9. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/startup.py +3 -3
  10. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/status.py +82 -13
  11. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/sync.py +108 -47
  12. agent_memory_cli-0.1.3/tests/conftest.py +112 -0
  13. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_debrief.py +1 -1
  14. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_doctor.py +80 -3
  15. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_events.py +3 -3
  16. agent_memory_cli-0.1.3/tests/test_prerequisites.py +161 -0
  17. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_setup.py +1 -1
  18. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_startup.py +22 -0
  19. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_status.py +97 -4
  20. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_sync.py +198 -45
  21. agent_memory_cli-0.1.1/tests/conftest.py +0 -60
  22. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/.gitignore +0 -0
  23. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/LICENSE +0 -0
  24. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/Makefile +0 -0
  25. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/scripts/check_installed_wheel.py +0 -0
  26. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/scripts/release_notes.py +0 -0
  27. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/__main__.py +0 -0
  28. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/archive.py +0 -0
  29. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/debrief.py +0 -0
  30. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/events.py +0 -0
  31. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/git_runner.py +0 -0
  32. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/home.py +0 -0
  33. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/layout.py +0 -0
  34. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/org.py +0 -0
  35. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/publication.py +0 -0
  36. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/setup/__init__.py +0 -0
  37. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/setup/claude.py +0 -0
  38. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/setup/codex.py +0 -0
  39. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/setup/common.py +0 -0
  40. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/setup/legacy.py +0 -0
  41. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/templates/org-memory/decisions.md +0 -0
  42. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/templates/org-memory/recent.md +0 -0
  43. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/templates/org-memory/rules.md +0 -0
  44. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/templates/project-memory/decision_log.md +0 -0
  45. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/templates/project-memory/known_debt.md +0 -0
  46. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/templates/project-memory/open_threads.md +0 -0
  47. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/templates/project-memory/project_facts.md +0 -0
  48. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/templates/workflow/SKILL.md +0 -0
  49. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/src/agent_memory/workflow.py +0 -0
  50. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/expected.json +0 -0
  51. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md +0 -0
  52. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  53. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md +0 -0
  54. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md +0 -0
  55. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md +0 -0
  56. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md +0 -0
  57. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/empty_store/expected.json +0 -0
  58. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep +0 -0
  59. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json +0 -0
  60. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md +0 -0
  61. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/staging_artifact/expected.json +0 -0
  62. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 +0 -0
  63. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  64. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/expected.json +0 -0
  65. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep +0 -0
  66. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md +0 -0
  67. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  68. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md +0 -0
  69. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md +0 -0
  70. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/fixtures/setup/claude_settings_fleet.json +0 -0
  71. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/fixtures/setup/codex_hooks_kernel.json +0 -0
  72. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/golden/canonical_memory_gitignore.txt +0 -0
  73. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/golden/debrief_record.md +0 -0
  74. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/golden/doctor_memory_0.4.5.json +0 -0
  75. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/golden/doctor_memory_0.4.5.txt +0 -0
  76. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/golden/event_record.md +0 -0
  77. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_archive.py +0 -0
  78. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_cli.py +0 -0
  79. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_home.py +0 -0
  80. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_layout.py +0 -0
  81. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_org.py +0 -0
  82. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.3}/tests/test_workflow.py +0 -0
@@ -4,6 +4,23 @@ All notable changes to agent-memory are documented in this file. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.1.3 - 2026-09-29
8
+
9
+ ### Fixed
10
+
11
+ - `pull`, `push`, and SessionStart sync can resolve homes with local memory changes behind upstream ([sync](docs/commands.md#sync)).
12
+
13
+ ## 0.1.2 - 2026-09-17
14
+
15
+ ### Added
16
+
17
+ - `status --json` prints the readout as one object, with an `ok` flag and a `prerequisites` block ([status](docs/commands.md#status)).
18
+
19
+ ### Fixed
20
+
21
+ - `status` and `doctor` exit non-zero with a reason code when a prerequisite such as git fails ([prerequisites](docs/commands.md#prerequisites)).
22
+ - `doctor` and `status --fetch` name a sandbox-blocked credential helper, not an unreachable remote ([doctor](docs/commands.md#doctor)).
23
+
7
24
  ## 0.1.1 - 2026-09-12
8
25
 
9
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-memory-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Cross-session memory for coding agents: plain files, git-native, no server
5
5
  Project-URL: Homepage, https://github.com/kiloloop/agent-memory
6
6
  Project-URL: Source, https://github.com/kiloloop/agent-memory
@@ -105,7 +105,7 @@ Python 3.10+ is required; sync needs git 2.25+, hooks use Bash, and
105
105
  agent-memory enable --remote git@github.com:YOUR_ORG/agent-memory-store.git
106
106
  agent-memory push --agent codex
107
107
  # After another machine pushes, with a clean local tree:
108
- agent-memory pull
108
+ agent-memory pull --agent claude
109
109
  ```
110
110
 
111
111
  Elsewhere, `agent-memory clone <remote-url> --home <local-home>`, then repeat
@@ -141,13 +141,14 @@ points into a home's `projects/` tree. Project selection uses `--project` or a
141
141
  matching binding/marker. OACP is not required.
142
142
 
143
143
  Sync makes the home a git repository with an allowlist and `.oacp-memory-repo`
144
- marker. `push` commits selected paths; `pull` fast-forwards a clean tree that
145
- is not ahead or diverged. No merges; keys and setup receipts stay local.
146
- Network verbs time out after 30 seconds.
144
+ marker. `push` and `pull --agent NAME` commit selected paths, rebase when behind,
145
+ and deliver. A plain `pull` updates a clean tree and reports unpushed commits.
146
+ Conflicts abort the rebase and retain local commits. Keys and setup receipts
147
+ stay local. Network and rebase operations time out after 30 seconds.
147
148
 
148
149
  `setup` installs a SessionStart hook and memory workflow. `startup` lists
149
150
  metadata for the four project files, then the three curated org files; it
150
- injects no content. `--pull` refreshes first, warning on failure. The workflow
151
+ injects no content. `--pull` commits and syncs first under the runtime identity, warning on failure. The workflow
151
152
  tells the agent to run `recall` for an 8,000-character bounded read. Raise
152
153
  `--max-chars` or read remaining files directly when cut. `capture` records
153
154
  decisions during work. At the end, update threads and debt, optionally publish
@@ -75,7 +75,7 @@ Python 3.10+ is required; sync needs git 2.25+, hooks use Bash, and
75
75
  agent-memory enable --remote git@github.com:YOUR_ORG/agent-memory-store.git
76
76
  agent-memory push --agent codex
77
77
  # After another machine pushes, with a clean local tree:
78
- agent-memory pull
78
+ agent-memory pull --agent claude
79
79
  ```
80
80
 
81
81
  Elsewhere, `agent-memory clone <remote-url> --home <local-home>`, then repeat
@@ -111,13 +111,14 @@ points into a home's `projects/` tree. Project selection uses `--project` or a
111
111
  matching binding/marker. OACP is not required.
112
112
 
113
113
  Sync makes the home a git repository with an allowlist and `.oacp-memory-repo`
114
- marker. `push` commits selected paths; `pull` fast-forwards a clean tree that
115
- is not ahead or diverged. No merges; keys and setup receipts stay local.
116
- Network verbs time out after 30 seconds.
114
+ marker. `push` and `pull --agent NAME` commit selected paths, rebase when behind,
115
+ and deliver. A plain `pull` updates a clean tree and reports unpushed commits.
116
+ Conflicts abort the rebase and retain local commits. Keys and setup receipts
117
+ stay local. Network and rebase operations time out after 30 seconds.
117
118
 
118
119
  `setup` installs a SessionStart hook and memory workflow. `startup` lists
119
120
  metadata for the four project files, then the three curated org files; it
120
- injects no content. `--pull` refreshes first, warning on failure. The workflow
121
+ injects no content. `--pull` commits and syncs first under the runtime identity, warning on failure. The workflow
121
122
  tells the agent to run `recall` for an 8,000-character bounded read. Raise
122
123
  `--max-chars` or read remaining files directly when cut. `capture` records
123
124
  decisions during work. At the end, update threads and debt, optionally publish
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling>=1.27"]
2
+ requires = ["hatchling>=1.32,<2"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
@@ -4,6 +4,6 @@
4
4
 
5
5
  from .home import HomeError, HomeResolution, resolve_home
6
6
 
7
- __version__ = "0.1.1"
7
+ __version__ = "0.1.3"
8
8
 
9
9
  __all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
@@ -10,7 +10,7 @@ import sys
10
10
  from pathlib import Path
11
11
  from typing import Callable, List, Optional, Sequence, Tuple
12
12
 
13
- from . import __version__, archive, debrief, doctor, events, org, setup, startup, status, sync, workflow
13
+ from . import __version__, archive, debrief, doctor, events, org, prerequisites, setup, startup, status, sync, workflow
14
14
  from .home import BINDING_FILE, HomeError, HomeResolution, find_project, resolve_home
15
15
 
16
16
  EXIT_OK = 0
@@ -51,16 +51,17 @@ def build_parser() -> argparse.ArgumentParser:
51
51
  "status",
52
52
  _status,
53
53
  "Show which home resolves, which rule chose it, whether the layout is in place, and where the sync stands. "
54
- "Exit 1 when the tree is dirty or diverged.",
54
+ "Exit 1 when the tree is dirty or diverged, or a prerequisite (git, the home, the credential helper) failed.",
55
55
  )
56
56
  status_.add_argument(
57
57
  "--fetch", action="store_true", help="contact the remote before counting ahead/behind (no network otherwise)"
58
58
  )
59
+ status_.add_argument("--json", dest="json_output", action="store_true", help="emit the readout as JSON")
59
60
  doctor_ = add(
60
61
  "doctor",
61
62
  _doctor,
62
63
  "Check the home's setup and health: the debrief store's layout and the sync repository. "
63
- "Reads no memory content; repairs nothing. Exit 1 on an error row.",
64
+ "Reads no memory content; repairs nothing. Exit 1 on an error row or a failed prerequisite.",
64
65
  )
65
66
  doctor_.add_argument("--json", dest="json_output", action="store_true", help="emit the report as JSON")
66
67
  init_ = add(
@@ -91,7 +92,7 @@ def build_parser() -> argparse.ArgumentParser:
91
92
  clone_ = add("clone", _clone, "Clone a memory repository into the home.")
92
93
  clone_.add_argument("url", help="git remote URL to clone")
93
94
  clone_.add_argument("--force", action="store_true", help="move a non-empty home aside before cloning")
94
- add("pull", _pull, "Fast-forward the home from its upstream when the tree is clean and not ahead.")
95
+ add("pull", _pull, "Update from upstream; with --agent, commit allowlisted changes and deliver them too.", agent)
95
96
  add("push", _push, "Commit the allowlisted memory changes and push them when a remote exists.", agent)
96
97
  add("disable", _disable, "Remove the sync marker; the repository stays in place.")
97
98
  for name, handler, help_text, positional, positional_help in (
@@ -120,7 +121,7 @@ def build_parser() -> argparse.ArgumentParser:
120
121
  "startup",
121
122
  _startup,
122
123
  "Print the session-start manifest: the memory files to read, in order, with their readability, size and "
123
- "age, and where the sync stands. Content is never included. With --pull, fast-forward the home first.",
124
+ "age, and where the sync stands. Content is never included. With --pull, commit and sync memory under the runtime identity first.",
124
125
  )
125
126
  startup_.add_argument("--runtime", choices=startup.RUNTIMES, required=True, help="shape the output for this runtime's hook")
126
127
  startup_.add_argument("--project", metavar="ID", help="the project tier to list (default: the one the binding or marker names)")
@@ -235,23 +236,44 @@ def _max_chars(value: str) -> int:
235
236
 
236
237
 
237
238
  def _status(args: argparse.Namespace) -> int:
238
- readout = status.inspect(resolve_home(args.home), fetch=args.fetch)
239
- print("\n".join(readout.lines()))
239
+ try:
240
+ resolution = resolve_home(args.home)
241
+ except HomeError as exc:
242
+ if not args.json_output:
243
+ raise
244
+ print(json.dumps(status.unresolved(exc), indent=2))
245
+ return EXIT_USAGE
246
+ readout = status.inspect(resolution, fetch=args.fetch)
247
+ if args.json_output:
248
+ print(json.dumps(readout.to_json(), indent=2))
249
+ else:
250
+ print("\n".join(readout.lines()))
240
251
  return readout.exit_code
241
252
 
242
253
 
243
254
  def _doctor(args: argparse.Namespace) -> int:
244
- home = resolve_home(args.home).path
255
+ memory_lint = doctor.find_memory_lint()
256
+ try:
257
+ home = resolve_home(args.home).path
258
+ except HomeError as exc:
259
+ if not args.json_output:
260
+ raise
261
+ unresolved = prerequisites.home_error(exc)
262
+ print(json.dumps(doctor.to_json([], memory_lint=memory_lint, prerequisites=unresolved), indent=2))
263
+ return EXIT_USAGE
245
264
  if not home.is_dir():
246
- print(f"agent-memory: error: {home} is not a directory", file=sys.stderr)
265
+ missing = prerequisites.home_missing(home)
266
+ if args.json_output:
267
+ print(json.dumps(doctor.to_json([], memory_lint=memory_lint, prerequisites=missing), indent=2))
268
+ elif missing.failure:
269
+ print(f"agent-memory: error: {missing.failure.line()}", file=sys.stderr)
247
270
  return EXIT_FAILED
248
- categories = doctor.run_doctor(home)
249
- memory_lint = doctor.find_memory_lint()
271
+ categories, readout = doctor.diagnose(home)
250
272
  if args.json_output:
251
- print(json.dumps(doctor.to_json(categories, memory_lint=memory_lint), indent=2))
273
+ print(json.dumps(doctor.to_json(categories, memory_lint=memory_lint, prerequisites=readout), indent=2))
252
274
  else:
253
275
  sys.stdout.write(doctor.report(categories, memory_lint=memory_lint))
254
- return EXIT_FAILED if doctor.has_errors(categories) else EXIT_OK
276
+ return EXIT_FAILED if doctor.has_errors(categories) or not readout.ok else EXIT_OK
255
277
 
256
278
 
257
279
  def _init(args: argparse.Namespace) -> int:
@@ -275,7 +297,7 @@ def _clone(args: argparse.Namespace) -> int:
275
297
 
276
298
 
277
299
  def _pull(args: argparse.Namespace) -> int:
278
- return _report(sync.pull(resolve_home(args.home).path))
300
+ return _report(sync.pull(resolve_home(args.home).path, agent=args.agent))
279
301
 
280
302
 
281
303
  def _push(args: argparse.Namespace) -> int:
@@ -36,8 +36,9 @@ from enum import Enum
36
36
  from pathlib import Path
37
37
  from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
38
38
 
39
- from . import layout, sync
39
+ from . import layout, prerequisites, sync
40
40
  from .git_runner import GitResult, GitRunner, run_git
41
+ from .prerequisites import Prerequisites
41
42
  from .sync import GitState
42
43
 
43
44
  #: A last commit older than this many days is reported stale.
@@ -109,7 +110,15 @@ def run_doctor(
109
110
  home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None
110
111
  ) -> List[Category]:
111
112
  """Both memory categories for ``home``, in report order."""
112
- return [check_org_memory(home), check_memory_sync(home, runner=runner, now=now)]
113
+ return diagnose(home, runner=runner, now=now)[0]
114
+
115
+
116
+ def diagnose(
117
+ home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None
118
+ ) -> Tuple[List[Category], Prerequisites]:
119
+ """Both memory categories in report order, and the prerequisite readout the sync checks took."""
120
+ memory_sync, readout = _memory_sync(home, runner=runner, now=now)
121
+ return [check_org_memory(home), memory_sync], readout
113
122
 
114
123
 
115
124
  def find_memory_lint(which: Callable[[str], Optional[str]] = shutil.which) -> Optional[str]:
@@ -135,10 +144,16 @@ def report(categories: Sequence[Category], *, memory_lint: Optional[str] = None)
135
144
  return "\n".join(lines) + "\n"
136
145
 
137
146
 
138
- def to_json(categories: Sequence[Category], *, memory_lint: Optional[str] = None) -> Dict[str, Any]:
139
- """The report as data, in the same order as the text."""
147
+ def to_json(
148
+ categories: Sequence[Category],
149
+ *,
150
+ memory_lint: Optional[str] = None,
151
+ prerequisites: Optional[Prerequisites] = None,
152
+ ) -> Dict[str, Any]:
153
+ """The report as data, in the same order as the text; ``prerequisites`` goes last, so the
154
+ rows before it read as they did without it."""
140
155
  output: Dict[str, Any] = {
141
- "has_errors": has_errors(categories),
156
+ "has_errors": has_errors(categories) or (prerequisites is not None and not prerequisites.ok),
142
157
  "memory_lint": memory_lint,
143
158
  "categories": [],
144
159
  }
@@ -152,6 +167,8 @@ def to_json(categories: Sequence[Category], *, memory_lint: Optional[str] = None
152
167
  output["categories"].append(
153
168
  {"name": category.name, "worst_severity": category.worst_severity.value, "results": rows}
154
169
  )
170
+ if prerequisites is not None:
171
+ output["prerequisites"] = prerequisites.to_json()
155
172
  return output
156
173
 
157
174
 
@@ -325,14 +342,21 @@ def check_memory_sync(
325
342
  home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None
326
343
  ) -> Category:
327
344
  """The sync setup and the repository's state, through git alone; nothing is changed."""
345
+ return _memory_sync(home, runner=runner, now=now)[0]
346
+
347
+
348
+ def _memory_sync(
349
+ home: Path, *, runner: Optional[GitRunner], now: Optional[dt.datetime]
350
+ ) -> Tuple[Category, Prerequisites]:
328
351
  cat = Category("Memory Sync")
329
352
  marker = layout.MARKER_FILE
353
+ git = prerequisites.probe_git(runner)
330
354
 
331
355
  try:
332
356
  configured = _is_file(sync.marker_path(home))
333
357
  except OSError as exc:
334
358
  cat.add("memory-marker", Severity.warn, f"{marker} — {_not_inspected(exc)}")
335
- return cat
359
+ return cat, prerequisites.assess(git, configured=False)
336
360
  if not configured:
337
361
  cat.add(
338
362
  "memory-marker",
@@ -340,9 +364,18 @@ def check_memory_sync(
340
364
  f"{marker} — not configured; memory sync hooks are disabled",
341
365
  "Run: agent-memory enable [--remote URL]",
342
366
  )
343
- return cat
367
+ return cat, prerequisites.assess(git, configured=False)
344
368
  cat.add("memory-marker", Severity.ok, f"{marker} — present")
345
369
 
370
+ if not git.present:
371
+ # Without git every readout below fails, and "not a git repository" would be the wrong finding.
372
+ cat.add(
373
+ "memory-git",
374
+ Severity.error,
375
+ f"{marker} — present, but {prerequisites.GIT_MISSING_TEXT}",
376
+ prerequisites.GIT_MISSING_REMEDY,
377
+ )
378
+ return cat, prerequisites.assess(git, configured=True)
346
379
  if not sync.is_git_repo(home, runner):
347
380
  cat.add(
348
381
  "memory-git",
@@ -350,7 +383,7 @@ def check_memory_sync(
350
383
  f"{marker} — present, but {home} is not a git repository",
351
384
  "Run `agent-memory enable`, or `agent-memory disable` to remove the marker",
352
385
  )
353
- return cat
386
+ return cat, prerequisites.assess(git, configured=True)
354
387
  enclosing = enclosing_repository(home, runner)
355
388
  if enclosing is not None:
356
389
  # The sync verbs refuse this home; reading the enclosing repository's state as the home's would be wrong.
@@ -361,7 +394,7 @@ def check_memory_sync(
361
394
  "a memory home must be the root of its own repository",
362
395
  "Move the home out of the enclosing repository, or `agent-memory disable` to remove the marker",
363
396
  )
364
- return cat
397
+ return cat, prerequisites.assess(git, configured=True)
365
398
 
366
399
  root_gitignore = home / layout.GITIGNORE_FILE
367
400
  try:
@@ -450,6 +483,10 @@ def check_memory_sync(
450
483
  if not state.has_remote:
451
484
  cat.add("sync-state", Severity.ok, text)
452
485
  cat.add("remote", Severity.skip, "remote — skipped; local-only memory repo")
486
+ elif prerequisites.helper_blocked(state):
487
+ # Not the network and not the remote's permissions: git never had credentials to try with.
488
+ cat.add("sync-state", Severity.error, text, prerequisites.HELPER_BLOCKED_REMEDY)
489
+ cat.add("remote", Severity.skip, "remote — not checked; the sandbox blocked the git credential helper")
453
490
  elif state.fetch_failed:
454
491
  cat.add("sync-state", Severity.warn, text, "Check network access and remote permissions")
455
492
  cat.add("remote", Severity.warn, "remote — not reachable", "Check network access and remote permissions")
@@ -523,7 +560,8 @@ def check_memory_sync(
523
560
  else:
524
561
  cat.add("memory-overlays", Severity.ok, f"memory .gitignore overlays — {len(overlays)} safe")
525
562
 
526
- return cat
563
+ fetched = state is not None and state.has_remote
564
+ return cat, prerequisites.assess(git, configured=True, state=state, fetched=fetched)
527
565
 
528
566
 
529
567
  def _add_working_tree(cat: Category, home: Path, runner: Optional[GitRunner], state: GitState) -> None:
@@ -568,10 +606,30 @@ def enclosing_repository(home: Path, runner: Optional[GitRunner] = None) -> Opti
568
606
  return root
569
607
 
570
608
 
609
+ def sync_state(state: GitState) -> str:
610
+ """Where the repository stands, as the code ``status --json`` carries beside :func:`sync_state_text`."""
611
+ if not state.has_remote:
612
+ return "local_only"
613
+ if state.fetch_failed:
614
+ return "fetch_failed"
615
+ if not state.has_upstream:
616
+ return "no_upstream"
617
+ if state.diverged:
618
+ return "diverged"
619
+ if state.behind:
620
+ return "behind"
621
+ if state.ahead:
622
+ return "ahead"
623
+ return "synced"
624
+
625
+
571
626
  def sync_state_text(state: GitState) -> str:
572
627
  """One phrase for where the repository stands; ``status`` and ``doctor`` share it."""
573
628
  if not state.has_remote:
574
629
  return "local-only; no remote configured"
630
+ if prerequisites.helper_blocked(state):
631
+ # The helper's own output names the secret it was after; the phrase names the cause instead.
632
+ return f"remote fetch failed: {prerequisites.HELPER_BLOCKED_TEXT}"
575
633
  if state.fetch_failed:
576
634
  return f"remote fetch failed: {state.fetch_output}"
577
635
  if not state.has_upstream:
@@ -0,0 +1,151 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """What ``status`` and ``doctor`` need before their readout means anything.
4
+
5
+ Three prerequisite failures, each with a stable reason code. The JSON carries
6
+ the code and the message, the table prints the same message, and either verb
7
+ exits non-zero on any of them:
8
+
9
+ * ``home_unresolved``: no memory home. The resolver failed, or the path it
10
+ chose is not a directory.
11
+ * ``git_missing``: git does not run, on a home that carries the sync marker. A
12
+ home without the marker needs no git, so there its absence is reported, not
13
+ failed. Without git a marked home would read as "not a git repository",
14
+ which is a layout finding it is not.
15
+ * ``credential_helper_blocked``: a fetch failed with the signature
16
+ :func:`agent_memory.sync.credential_helper_blocked` detects, a git
17
+ credential helper that a sandbox stopped. The helper runs only when git has
18
+ no cached credential for the remote, so the failure needs a cold cache as
19
+ well as the sandbox.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, Dict, Optional
28
+
29
+ from . import sync
30
+ from .git_runner import EXIT_NOT_FOUND, GitRunner, run_git
31
+ from .sync import GitState
32
+
33
+ HOME_UNRESOLVED = "home_unresolved"
34
+ GIT_MISSING = "git_missing"
35
+ CREDENTIAL_HELPER_BLOCKED = "credential_helper_blocked"
36
+ #: Every reason code, in the order the checks run.
37
+ REASONS = (HOME_UNRESOLVED, GIT_MISSING, CREDENTIAL_HELPER_BLOCKED)
38
+
39
+ GIT_MISSING_TEXT = "git could not be run from PATH"
40
+ GIT_MISSING_REMEDY = "Install git, or put it on PATH"
41
+ HELPER_BLOCKED_TEXT = (
42
+ "the git credential helper could not run under the sandbox, so git had no credentials for the remote"
43
+ )
44
+ HELPER_BLOCKED_REMEDY = "Rerun with the sandbox off"
45
+ HOME_MISSING_REMEDY = "Pass --home or set $AGENT_MEMORY_HOME to an existing home, or create one with `agent-memory init`"
46
+ HOME_ERROR_REMEDY = "Fix the binding or path it names, or pass --home"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Failure:
51
+ """One failed prerequisite: its reason code, what happened, and what to do."""
52
+
53
+ reason: str
54
+ message: str
55
+ remedy: str
56
+
57
+ def line(self) -> str:
58
+ return f"{self.reason} — {self.message}. {self.remedy}."
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class GitProbe:
63
+ """Whether git runs, and the version it reports (empty when it reported none)."""
64
+
65
+ present: bool
66
+ version: str = ""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Prerequisites:
71
+ """The prerequisite readout both verbs print."""
72
+
73
+ #: ``None`` when git was not probed, because there was no home to probe it in.
74
+ git: Optional[GitProbe] = None
75
+ #: Whether a fetch ran, the only time the credential helper can be judged.
76
+ helper_checked: bool = False
77
+ helper_blocked: bool = False
78
+ failure: Optional[Failure] = None
79
+
80
+ @property
81
+ def ok(self) -> bool:
82
+ return self.failure is None
83
+
84
+ @property
85
+ def reason(self) -> Optional[str]:
86
+ return self.failure.reason if self.failure else None
87
+
88
+ def to_json(self) -> Dict[str, Any]:
89
+ failure = self.failure
90
+ return {
91
+ "ok": self.ok,
92
+ "reason": failure.reason if failure else None,
93
+ "message": failure.message if failure else None,
94
+ "remedy": failure.remedy if failure else None,
95
+ "git": {
96
+ "present": self.git.present if self.git else None,
97
+ "version": (self.git.version or None) if self.git else None,
98
+ },
99
+ "credential_helper": {
100
+ "checked": self.helper_checked,
101
+ "blocked": self.helper_blocked if self.helper_checked else None,
102
+ },
103
+ }
104
+
105
+
106
+ #: Where the probe runs git. ``git --version`` reads nothing from its working directory, and the
107
+ #: filesystem root is always there, so a missing or unreadable home cannot pass for a missing git.
108
+ _PROBE_CWD = Path(os.path.abspath(os.sep))
109
+
110
+
111
+ def probe_git(runner: Optional[GitRunner] = None) -> GitProbe:
112
+ """Run ``git --version``. A git absent from PATH, or one the system will not execute, does not run."""
113
+ try:
114
+ result = (runner or run_git)(["--version"], cwd=_PROBE_CWD)
115
+ except OSError:
116
+ # The runner reports an absent git itself; this is a git it found and could not execute.
117
+ return GitProbe(False)
118
+ if result.returncode == EXIT_NOT_FOUND:
119
+ return GitProbe(False)
120
+ words = result.stdout.split()
121
+ version = words[2] if result.ok and words[:2] == ["git", "version"] and len(words) > 2 else ""
122
+ return GitProbe(True, version)
123
+
124
+
125
+ def helper_blocked(state: Optional[GitState]) -> bool:
126
+ """Did this readout's fetch fail because a sandbox blocked the credential helper?"""
127
+ return state is not None and state.fetch_failed and sync.credential_helper_blocked(state.fetch_output)
128
+
129
+
130
+ def home_missing(home: Path) -> Prerequisites:
131
+ return Prerequisites(failure=Failure(HOME_UNRESOLVED, f"{home} is not a directory", HOME_MISSING_REMEDY))
132
+
133
+
134
+ def home_error(error: Exception) -> Prerequisites:
135
+ return Prerequisites(
136
+ failure=Failure(HOME_UNRESOLVED, f"the memory home could not be resolved: {error}", HOME_ERROR_REMEDY)
137
+ )
138
+
139
+
140
+ def assess(
141
+ git: GitProbe, *, configured: bool, state: Optional[GitState] = None, fetched: bool = False
142
+ ) -> Prerequisites:
143
+ """The readout for a home that exists. Git is required once the home carries the sync
144
+ marker; the credential helper is judged only when ``fetched``."""
145
+ blocked = fetched and helper_blocked(state)
146
+ failure: Optional[Failure] = None
147
+ if configured and not git.present:
148
+ failure = Failure(GIT_MISSING, GIT_MISSING_TEXT, GIT_MISSING_REMEDY)
149
+ elif blocked:
150
+ failure = Failure(CREDENTIAL_HELPER_BLOCKED, HELPER_BLOCKED_TEXT, HELPER_BLOCKED_REMEDY)
151
+ return Prerequisites(git, fetched, blocked, failure)
@@ -72,7 +72,7 @@ def build_manifest(
72
72
  warnings: List[str] = list(notes)
73
73
  files: List[Dict[str, Any]] = []
74
74
 
75
- sync_info, pull_info = _sync(home, pull, runner, warnings)
75
+ sync_info, pull_info = _sync(home, pull, runner, warnings, runtime)
76
76
  if project is not None:
77
77
  try:
78
78
  layout.validate_project_name(project)
@@ -154,14 +154,14 @@ def _entry(home: Path, path: Path, tier: str, name: str) -> Dict[str, Any]:
154
154
 
155
155
 
156
156
  def _sync(
157
- home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str]
157
+ home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str], runtime: str
158
158
  ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
159
159
  marker = sync.is_configured(home)
160
160
  info: Dict[str, Any] = {"marker": marker, "last_commit_at_utc": None}
161
161
  pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []}
162
162
  if pull:
163
163
  try:
164
- outcome = sync.pull(home, runner=runner)
164
+ outcome = sync.pull(home, agent=runtime, runner=runner)
165
165
  pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)}
166
166
  except (sync.SyncError, OSError) as exc:
167
167
  pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]}