agent-memory-cli 0.1.1__tar.gz → 0.1.2__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.2}/CHANGELOG.md +11 -0
  2. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/PKG-INFO +1 -1
  3. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/pyproject.toml +1 -1
  4. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/__init__.py +1 -1
  5. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/cli.py +33 -11
  6. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/doctor.py +68 -10
  7. agent_memory_cli-0.1.2/src/agent_memory/prerequisites.py +151 -0
  8. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/status.py +82 -13
  9. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/sync.py +4 -3
  10. agent_memory_cli-0.1.2/tests/conftest.py +112 -0
  11. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_debrief.py +1 -1
  12. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_doctor.py +80 -3
  13. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_events.py +3 -3
  14. agent_memory_cli-0.1.2/tests/test_prerequisites.py +161 -0
  15. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_setup.py +1 -1
  16. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_status.py +97 -4
  17. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_sync.py +4 -34
  18. agent_memory_cli-0.1.1/tests/conftest.py +0 -60
  19. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/.gitignore +0 -0
  20. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/LICENSE +0 -0
  21. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/Makefile +0 -0
  22. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/README.md +0 -0
  23. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/scripts/check_installed_wheel.py +0 -0
  24. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/scripts/release_notes.py +0 -0
  25. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/__main__.py +0 -0
  26. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/archive.py +0 -0
  27. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/debrief.py +0 -0
  28. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/events.py +0 -0
  29. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/git_runner.py +0 -0
  30. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/home.py +0 -0
  31. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/layout.py +0 -0
  32. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/org.py +0 -0
  33. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/publication.py +0 -0
  34. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/setup/__init__.py +0 -0
  35. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/setup/claude.py +0 -0
  36. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/setup/codex.py +0 -0
  37. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/setup/common.py +0 -0
  38. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/setup/legacy.py +0 -0
  39. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/startup.py +0 -0
  40. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/templates/org-memory/decisions.md +0 -0
  41. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/templates/org-memory/recent.md +0 -0
  42. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/templates/org-memory/rules.md +0 -0
  43. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/templates/project-memory/decision_log.md +0 -0
  44. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/templates/project-memory/known_debt.md +0 -0
  45. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/templates/project-memory/open_threads.md +0 -0
  46. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/templates/project-memory/project_facts.md +0 -0
  47. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/templates/workflow/SKILL.md +0 -0
  48. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/src/agent_memory/workflow.py +0 -0
  49. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/expected.json +0 -0
  50. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md +0 -0
  51. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  52. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md +0 -0
  53. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md +0 -0
  54. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md +0 -0
  55. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md +0 -0
  56. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/empty_store/expected.json +0 -0
  57. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep +0 -0
  58. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json +0 -0
  59. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md +0 -0
  60. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/staging_artifact/expected.json +0 -0
  61. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 +0 -0
  62. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  63. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/valid_store/expected.json +0 -0
  64. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep +0 -0
  65. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md +0 -0
  66. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  67. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md +0 -0
  68. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md +0 -0
  69. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/fixtures/setup/claude_settings_fleet.json +0 -0
  70. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/fixtures/setup/codex_hooks_kernel.json +0 -0
  71. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/golden/canonical_memory_gitignore.txt +0 -0
  72. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/golden/debrief_record.md +0 -0
  73. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/golden/doctor_memory_0.4.5.json +0 -0
  74. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/golden/doctor_memory_0.4.5.txt +0 -0
  75. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/golden/event_record.md +0 -0
  76. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_archive.py +0 -0
  77. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_cli.py +0 -0
  78. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_home.py +0 -0
  79. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_layout.py +0 -0
  80. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_org.py +0 -0
  81. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_startup.py +0 -0
  82. {agent_memory_cli-0.1.1 → agent_memory_cli-0.1.2}/tests/test_workflow.py +0 -0
@@ -4,6 +4,17 @@ 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.2 - 2026-09-17
8
+
9
+ ### Added
10
+
11
+ - `status --json` prints the readout as one object, with an `ok` flag and a `prerequisites` block ([status](docs/commands.md#status)).
12
+
13
+ ### Fixed
14
+
15
+ - `status` and `doctor` exit non-zero with a reason code when a prerequisite such as git fails ([prerequisites](docs/commands.md#prerequisites)).
16
+ - `doctor` and `status --fetch` name a sandbox-blocked credential helper, not an unreachable remote ([doctor](docs/commands.md#doctor)).
17
+
7
18
  ## 0.1.1 - 2026-09-12
8
19
 
9
20
  ### 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.2
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
@@ -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.2"
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(
@@ -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:
@@ -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)
@@ -7,20 +7,23 @@ project), the layout (marker, allowlist, tiers), and, when the home is a sync
7
7
  repository, its :class:`~agent_memory.sync.GitState`. The remote is contacted
8
8
  only with ``--fetch``; otherwise ahead/behind count against the last fetched
9
9
  upstream. Exit 0 when the tree is clean and not diverged, 1 when it is dirty
10
- or diverged (or the home does not exist). Ahead and behind are reported, not
11
- failed: they are what ``push`` and ``pull`` are for.
10
+ or diverged, or a prerequisite failed (:mod:`agent_memory.prerequisites`).
11
+ Ahead and behind are reported, not failed: they are what ``push`` and ``pull``
12
+ are for. ``--json`` prints the same readout as data, with ``ok`` true exactly
13
+ when the exit is 0.
12
14
  """
13
15
 
14
16
  from __future__ import annotations
15
17
 
16
18
  from dataclasses import dataclass
17
19
  from pathlib import Path
18
- from typing import List, Optional
20
+ from typing import Any, Dict, List, Optional
19
21
 
20
- from . import layout, sync
21
- from .doctor import enclosing_repository, sync_state_text
22
+ from . import layout, prerequisites, sync
23
+ from .doctor import enclosing_repository, sync_state, sync_state_text
22
24
  from .git_runner import GitRunner
23
25
  from .home import HomeResolution
26
+ from .prerequisites import Prerequisites
24
27
  from .sync import GitState
25
28
 
26
29
  EXIT_OK = 0
@@ -39,13 +42,14 @@ class Status:
39
42
  gitignore: str = ""
40
43
  org_memory: bool = False
41
44
  projects: int = 0
42
- #: ``None`` without the marker; otherwise whether the home is a git repository.
45
+ #: ``None`` without the marker or without git; otherwise whether the home is a git repository.
43
46
  repository: Optional[bool] = None
44
47
  #: Set when the home is a repository only by sitting inside another one.
45
48
  enclosing: Optional[Path] = None
46
49
  git: Optional[GitState] = None
47
50
  #: Whether the remote was contacted for this readout.
48
51
  fetched: bool = False
52
+ prerequisites: Prerequisites = Prerequisites()
49
53
 
50
54
  @property
51
55
  def clean(self) -> bool:
@@ -54,9 +58,13 @@ class Status:
54
58
  return False
55
59
  return self.git is None or not (self.git.dirty or self.git.diverged)
56
60
 
61
+ @property
62
+ def ok(self) -> bool:
63
+ return self.prerequisites.ok and self.clean
64
+
57
65
  @property
58
66
  def exit_code(self) -> int:
59
- return EXIT_OK if self.clean else EXIT_FAILED
67
+ return EXIT_OK if self.ok else EXIT_FAILED
60
68
 
61
69
  def lines(self) -> List[str]:
62
70
  lines = [f"home: {self.home}", f"source: {self.source}"]
@@ -64,7 +72,7 @@ class Status:
64
72
  lines.append(f"project: {self.project}")
65
73
  if not self.exists:
66
74
  lines.append("exists: no")
67
- return lines
75
+ return self._with_failure(lines)
68
76
  lines.extend(
69
77
  [
70
78
  "exists: yes",
@@ -74,8 +82,10 @@ class Status:
74
82
  f"projects: {self.projects} with a memory dir",
75
83
  ]
76
84
  )
77
- if self.repository is None:
85
+ if not self.marker:
78
86
  lines.append("sync: not configured")
87
+ elif self.prerequisites.reason == prerequisites.GIT_MISSING:
88
+ lines.append(f"sync: not read ({prerequisites.GIT_MISSING})")
79
89
  elif not self.repository:
80
90
  lines.append("sync: marker present, but the home is not a git repository")
81
91
  elif self.enclosing is not None:
@@ -85,26 +95,70 @@ class Status:
85
95
  if self.git.has_remote:
86
96
  lines.append("fetch: done" if self.fetched else "fetch: skipped (pass --fetch to contact the remote)")
87
97
  lines.append(f"tree: {'dirty' if self.git.dirty else 'clean'}")
88
- return lines
98
+ return self._with_failure(lines)
99
+
100
+ def _with_failure(self, lines: List[str]) -> List[str]:
101
+ failure = self.prerequisites.failure
102
+ return lines + [f"prerequisite failed: {failure.line()}"] if failure else lines
103
+
104
+ def to_json(self) -> Dict[str, Any]:
105
+ """The readout as data: the fields the lines show, and ``ok`` for the exit code."""
106
+ git = self.git
107
+ return {
108
+ "ok": self.ok,
109
+ "home": str(self.home),
110
+ "source": self.source,
111
+ "project": self.project,
112
+ "exists": self.exists,
113
+ "layout": {
114
+ "marker": self.marker,
115
+ "gitignore": self.gitignore,
116
+ "org_memory": self.org_memory,
117
+ "projects": self.projects,
118
+ }
119
+ if self.exists
120
+ else None,
121
+ "sync": {
122
+ "configured": self.marker,
123
+ "repository": self.repository,
124
+ "enclosing": str(self.enclosing) if self.enclosing is not None else None,
125
+ "state": sync_state(git) if git else None,
126
+ "state_text": sync_state_text(git) if git else None,
127
+ "remote": git.has_remote if git else None,
128
+ "upstream": (git.upstream or None) if git else None,
129
+ "fetched": self.fetched,
130
+ "ahead": git.ahead if git else None,
131
+ "behind": git.behind if git else None,
132
+ "dirty": git.dirty if git else None,
133
+ "diverged": git.diverged if git else None,
134
+ }
135
+ if self.exists
136
+ else None,
137
+ "prerequisites": self.prerequisites.to_json(),
138
+ }
89
139
 
90
140
 
91
141
  def inspect(resolution: HomeResolution, *, fetch: bool = False, runner: Optional[GitRunner] = None) -> Status:
92
142
  """Read the home ``resolution`` names; nothing is changed, and no network without ``fetch``."""
93
143
  home = resolution.path
94
144
  if not home.is_dir():
95
- return Status(home, resolution.source, resolution.project, exists=False)
145
+ return Status(
146
+ home, resolution.source, resolution.project, exists=False, prerequisites=prerequisites.home_missing(home)
147
+ )
96
148
  tiers = layout.allowed_memory_dirs(home)
97
149
  org = layout.org_memory_dir(home)
98
150
  marker = sync.is_configured(home)
151
+ probe = prerequisites.probe_git(runner)
99
152
  repository: Optional[bool] = None
100
153
  enclosing: Optional[Path] = None
101
154
  git: Optional[GitState] = None
102
- if marker:
155
+ if marker and probe.present:
103
156
  repository = sync.is_git_repo(home, runner)
104
157
  if repository:
105
158
  enclosing = enclosing_repository(home, runner)
106
159
  if repository and enclosing is None:
107
160
  git = sync.git_state(home, runner=runner, fetch=fetch)
161
+ fetched = fetch and git is not None and git.has_remote
108
162
  return Status(
109
163
  home,
110
164
  resolution.source,
@@ -117,10 +171,25 @@ def inspect(resolution: HomeResolution, *, fetch: bool = False, runner: Optional
117
171
  repository=repository,
118
172
  enclosing=enclosing,
119
173
  git=git,
120
- fetched=fetch and git is not None and git.has_remote,
174
+ fetched=fetched,
175
+ prerequisites=prerequisites.assess(probe, configured=marker, state=git, fetched=fetched),
121
176
  )
122
177
 
123
178
 
179
+ def unresolved(error: Exception) -> Dict[str, Any]:
180
+ """The JSON readout when the resolver itself failed, so there is no home to read."""
181
+ return {
182
+ "ok": False,
183
+ "home": None,
184
+ "source": None,
185
+ "project": None,
186
+ "exists": False,
187
+ "layout": None,
188
+ "sync": None,
189
+ "prerequisites": prerequisites.home_error(error).to_json(),
190
+ }
191
+
192
+
124
193
  def gitignore_state(home: Path) -> str:
125
194
  """How the root ``.gitignore`` relates to the canonical allowlist, in a few words."""
126
195
  path = home / layout.GITIGNORE_FILE
@@ -547,7 +547,7 @@ def _deliver(
547
547
  receipt: Optional[GitignoreReceipt] = None,
548
548
  ) -> Outcome:
549
549
  if state.fetch_failed:
550
- if _credential_helper_blocked(state.fetch_output):
550
+ if credential_helper_blocked(state.fetch_output):
551
551
  return _helper_blocked(lines, published, receipt)
552
552
  failure = _fetch_failure(state, "memory push")
553
553
  lines.append(f"{failure.lines[0]} The commit remains local.")
@@ -557,7 +557,7 @@ def _deliver(
557
557
  return Outcome("up_to_date", True, tuple(lines), (), published.preserved, receipt)
558
558
  result = _push_remote(home, state, runner)
559
559
  if not result.ok:
560
- if _credential_helper_blocked(result.output):
560
+ if credential_helper_blocked(result.output):
561
561
  return _helper_blocked(lines, published, receipt)
562
562
  what = "timed out" if result.timed_out else "was rejected"
563
563
  lines.append(f"memory push: the push {what}; the commit remains local. {result.output}".rstrip())
@@ -575,9 +575,10 @@ def _push_remote(home: Path, state: GitState, runner: Optional[GitRunner]) -> Gi
575
575
  return _git(home, ["push", "--quiet", "-u", remote, branch], runner, timeout=NETWORK_TIMEOUT_SECONDS)
576
576
 
577
577
 
578
- def _credential_helper_blocked(output: str) -> bool:
578
+ def credential_helper_blocked(output: str) -> bool:
579
579
  """Does this git failure carry the blocked-credential-helper signature?
580
580
 
581
+ The one detector: ``doctor`` and ``status`` read fetch output through it too.
581
582
  git falls back to prompting when a helper hands it nothing, and under a
582
583
  sandbox that prompt has no terminal to read from either. Both halves are
583
584
  required, and the second must come from the helper: a prompt failure on