agent-memory-cli 0.1.0__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.0 → agent_memory_cli-0.1.2}/CHANGELOG.md +26 -0
  2. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/PKG-INFO +4 -2
  3. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/README.md +2 -0
  4. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/pyproject.toml +2 -2
  5. agent_memory_cli-0.1.2/scripts/release_notes.py +78 -0
  6. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/__init__.py +1 -1
  7. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/cli.py +144 -11
  8. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/doctor.py +103 -19
  9. agent_memory_cli-0.1.2/src/agent_memory/events.py +409 -0
  10. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/layout.py +17 -1
  11. agent_memory_cli-0.1.2/src/agent_memory/prerequisites.py +151 -0
  12. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/status.py +82 -13
  13. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/sync.py +109 -24
  14. agent_memory_cli-0.1.2/tests/conftest.py +112 -0
  15. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/golden/canonical_memory_gitignore.txt +2 -2
  16. agent_memory_cli-0.1.2/tests/golden/event_record.md +14 -0
  17. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_cli.py +3 -2
  18. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_debrief.py +1 -1
  19. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_doctor.py +148 -3
  20. agent_memory_cli-0.1.2/tests/test_events.py +706 -0
  21. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_layout.py +17 -1
  22. agent_memory_cli-0.1.2/tests/test_prerequisites.py +161 -0
  23. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_setup.py +1 -1
  24. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_status.py +97 -4
  25. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_sync.py +146 -0
  26. agent_memory_cli-0.1.0/tests/conftest.py +0 -60
  27. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/.gitignore +0 -0
  28. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/LICENSE +0 -0
  29. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/Makefile +0 -0
  30. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/scripts/check_installed_wheel.py +0 -0
  31. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/__main__.py +0 -0
  32. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/archive.py +0 -0
  33. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/debrief.py +0 -0
  34. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/git_runner.py +0 -0
  35. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/home.py +0 -0
  36. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/org.py +0 -0
  37. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/publication.py +0 -0
  38. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/setup/__init__.py +0 -0
  39. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/setup/claude.py +0 -0
  40. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/setup/codex.py +0 -0
  41. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/setup/common.py +0 -0
  42. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/setup/legacy.py +0 -0
  43. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/startup.py +0 -0
  44. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/templates/org-memory/decisions.md +0 -0
  45. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/templates/org-memory/recent.md +0 -0
  46. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/templates/org-memory/rules.md +0 -0
  47. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/templates/project-memory/decision_log.md +0 -0
  48. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/templates/project-memory/known_debt.md +0 -0
  49. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/templates/project-memory/open_threads.md +0 -0
  50. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/templates/project-memory/project_facts.md +0 -0
  51. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/templates/workflow/SKILL.md +0 -0
  52. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/src/agent_memory/workflow.py +0 -0
  53. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/expected.json +0 -0
  54. {agent_memory_cli-0.1.0 → 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
  55. {agent_memory_cli-0.1.0 → 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
  56. {agent_memory_cli-0.1.0 → 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
  57. {agent_memory_cli-0.1.0 → 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
  58. {agent_memory_cli-0.1.0 → 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
  59. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md +0 -0
  60. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/empty_store/expected.json +0 -0
  61. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep +0 -0
  62. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json +0 -0
  63. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md +0 -0
  64. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/staging_artifact/expected.json +0 -0
  65. {agent_memory_cli-0.1.0 → 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
  66. {agent_memory_cli-0.1.0 → 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
  67. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/valid_store/expected.json +0 -0
  68. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep +0 -0
  69. {agent_memory_cli-0.1.0 → 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
  70. {agent_memory_cli-0.1.0 → 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
  71. {agent_memory_cli-0.1.0 → 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
  72. {agent_memory_cli-0.1.0 → 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
  73. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/fixtures/setup/claude_settings_fleet.json +0 -0
  74. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/fixtures/setup/codex_hooks_kernel.json +0 -0
  75. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/golden/debrief_record.md +0 -0
  76. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/golden/doctor_memory_0.4.5.json +0 -0
  77. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/golden/doctor_memory_0.4.5.txt +0 -0
  78. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_archive.py +0 -0
  79. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_home.py +0 -0
  80. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_org.py +0 -0
  81. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_startup.py +0 -0
  82. {agent_memory_cli-0.1.0 → agent_memory_cli-0.1.2}/tests/test_workflow.py +0 -0
@@ -4,6 +4,32 @@ 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
+
18
+ ## 0.1.1 - 2026-09-12
19
+
20
+ ### Added
21
+
22
+ - `event write` publishes one org-memory event under `org-memory/events/`, never replacing a published record ([event](docs/commands.md#event-write)).
23
+ - A `v*` tag attaches the built sdist and wheel to the GitHub Release ([releases](https://github.com/kiloloop/agent-memory/releases)).
24
+
25
+ ### Fixed
26
+
27
+ - A nested `.gitignore` or `.oacp-memory-repo` no longer blocks `pull`; the allowlist anchors both names to the home root ([sync](docs/commands.md#sync)).
28
+ - `enable` retires superseded lines when it updates a home's `.gitignore`, and the receipt names them ([sync](docs/commands.md#sync)).
29
+ - `doctor` scopes its working-tree row to the allowlist: memory changes read DIRTY, and changes elsewhere are named as outside it ([doctor](docs/commands.md#doctor)).
30
+ - `push` and `doctor` keep both paths of a rename git detects in the working tree; the source path was previously misread ([sync](docs/commands.md#sync)).
31
+ - `push` names a credential helper a sandbox blocked as its own failure, with the remedy, instead of reporting it as a rejected push ([sync](docs/commands.md#sync)).
32
+
7
33
  ## 0.1.0 - 2026-09-06
8
34
 
9
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-memory-cli
3
- Version: 0.1.0
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
@@ -24,7 +24,7 @@ Requires-Python: >=3.10
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: build<2,>=1.2; extra == 'dev'
26
26
  Requires-Dist: pytest<10,>=8; extra == 'dev'
27
- Requires-Dist: ruff<1,>=0.11; extra == 'dev'
27
+ Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
28
28
  Requires-Dist: twine<8,>=7; extra == 'dev'
29
29
  Description-Content-Type: text/markdown
30
30
 
@@ -177,6 +177,7 @@ similarity queries, synthesis, or indexing. Recall reads a fixed file set.
177
177
  | [`capture`][capture-and-recall] | Record a decision. |
178
178
  | [`recall`][capture-and-recall] | Read bounded context. |
179
179
  | [`debrief write`][debrief-write] | Publish a session summary. |
180
+ | [`event write`][event-write] | Publish an org-memory event. |
180
181
 
181
182
  [status]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#status
182
183
  [doctor]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#doctor
@@ -187,6 +188,7 @@ similarity queries, synthesis, or indexing. Recall reads a fixed file set.
187
188
  [startup]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#startup
188
189
  [capture-and-recall]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#capture-and-recall
189
190
  [debrief-write]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#debrief-write
191
+ [event-write]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#event-write
190
192
 
191
193
  ## Examples
192
194
 
@@ -147,6 +147,7 @@ similarity queries, synthesis, or indexing. Recall reads a fixed file set.
147
147
  | [`capture`][capture-and-recall] | Record a decision. |
148
148
  | [`recall`][capture-and-recall] | Read bounded context. |
149
149
  | [`debrief write`][debrief-write] | Publish a session summary. |
150
+ | [`event write`][event-write] | Publish an org-memory event. |
150
151
 
151
152
  [status]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#status
152
153
  [doctor]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#doctor
@@ -157,6 +158,7 @@ similarity queries, synthesis, or indexing. Recall reads a fixed file set.
157
158
  [startup]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#startup
158
159
  [capture-and-recall]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#capture-and-recall
159
160
  [debrief-write]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#debrief-write
161
+ [event-write]: https://github.com/kiloloop/agent-memory/blob/main/docs/commands.md#event-write
160
162
 
161
163
  ## Examples
162
164
 
@@ -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]
@@ -38,7 +38,7 @@ Changelog = "https://github.com/kiloloop/agent-memory/blob/main/CHANGELOG.md"
38
38
  dev = [
39
39
  "build>=1.2,<2",
40
40
  "pytest>=8,<10",
41
- "ruff>=0.11,<1",
41
+ "ruff>=0.16,<0.17",
42
42
  "twine>=7,<8",
43
43
  ]
44
44
 
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-FileCopyrightText: 2026 Kiloloop
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Print the changelog section for one release, as GitHub Release notes.
5
+
6
+ The release workflow feeds this to ``gh release create --notes-file``. GitHub's own
7
+ ``--generate-notes`` cannot serve here: the public repository receives each version as a
8
+ single squashed commit, so the generated summary is one line no matter how much the
9
+ release contains. The changelog section already says what shipped, one line per change.
10
+
11
+ Two transformations make a changelog section stand alone on a release page:
12
+
13
+ 1. Relative links (``docs/commands.md#init``) are rewritten against the tag. A release
14
+ body is not rendered relative to the repository root, so a relative target 404s; and
15
+ pinning to the tag rather than to ``main`` keeps old release notes pointing at the
16
+ documentation as it stood for that version.
17
+ 2. The section heading itself is dropped — the release already carries the version.
18
+
19
+ Exits 1 when the section is absent, so the caller can fall back rather than publish a
20
+ release with an empty body.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ LINK = re.compile(r"\]\(([^)]+)\)")
31
+
32
+
33
+ def extract(changelog: str, version: str) -> str | None:
34
+ """Return the body of ``## <version>``, or None when no such section exists."""
35
+ lines = changelog.split("\n")
36
+ start = None
37
+ for i, line in enumerate(lines):
38
+ # "## 0.1.0 - 2026-09-06" and "## [0.1.0] - ..." both name this version.
39
+ if line.startswith("## ") and re.match(rf"##\s+\[?{re.escape(version)}\]?\b", line):
40
+ start = i + 1
41
+ break
42
+ if start is None:
43
+ return None
44
+ body: list[str] = []
45
+ for line in lines[start:]:
46
+ if line.startswith("## "):
47
+ break
48
+ body.append(line)
49
+ return "\n".join(body).strip() or None
50
+
51
+
52
+ def absolutise(notes: str, repository: str, tag: str) -> str:
53
+ """Rewrite repo-relative link targets to absolute URLs pinned at ``tag``."""
54
+ base = f"https://github.com/{repository}/blob/{tag}/"
55
+ return LINK.sub(
56
+ lambda m: f"]({m.group(1)})" if m.group(1).startswith("http") else f"]({base}{m.group(1)})",
57
+ notes,
58
+ )
59
+
60
+
61
+ def main() -> int:
62
+ parser = argparse.ArgumentParser(description=__doc__)
63
+ parser.add_argument("--tag", required=True, help="release tag, e.g. v0.1.0")
64
+ parser.add_argument("--repository", required=True, help="owner/repo, for absolute links")
65
+ parser.add_argument("--changelog", default="CHANGELOG.md", type=Path)
66
+ args = parser.parse_args()
67
+
68
+ version = args.tag[1:] if args.tag.startswith("v") else args.tag
69
+ notes = extract(args.changelog.read_text(encoding="utf-8"), version)
70
+ if notes is None:
71
+ print(f"no changelog section for {version} in {args.changelog}", file=sys.stderr)
72
+ return 1
73
+ print(absolutise(notes, args.repository, args.tag))
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())
@@ -4,6 +4,6 @@
4
4
 
5
5
  from .home import HomeError, HomeResolution, resolve_home
6
6
 
7
- __version__ = "0.1.0"
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, 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(
@@ -189,6 +190,38 @@ def build_parser() -> argparse.ArgumentParser:
189
190
  write.add_argument("--oacp-dir", dest="home", metavar="PATH", help=argparse.SUPPRESS)
190
191
  write.add_argument("--dry-run", action="store_true", help="validate and compose the record, print it, and write nothing")
191
192
  write.add_argument("--json", dest="json_output", action="store_true", help="emit a machine-readable result")
193
+ event_ = commands.add_parser("event", help="Org-memory event commands.", description="Org-memory event commands.")
194
+ event_commands = event_.add_subparsers(dest="event_command", metavar="<command>")
195
+ event_help = (
196
+ "Publish one org-memory event into the home's events store, failure-atomically: the canonical path only ever "
197
+ "holds a complete, verified record, byte-identical to the kernel's write-event script for the same inputs. "
198
+ "Exit 0 published (or an identical record was already there), 1 on a validation error, 2 on a publication "
199
+ "failure."
200
+ )
201
+ event_write = event_commands.add_parser("write", parents=[common], help=event_help, description=event_help)
202
+ event_write.set_defaults(handler=_event_write)
203
+ event_write.add_argument("--agent", required=True, help="agent creating the event")
204
+ event_write.add_argument("--project", required=True, help="originating project")
205
+ event_write.add_argument(
206
+ "--type", dest="event_type", required=True, choices=sorted(events.ALLOWED_TYPES), help="event type"
207
+ )
208
+ event_write.add_argument(
209
+ "--slug", required=True, help="short slug for the filename: lowercase alphanumerics and hyphens, no dots"
210
+ )
211
+ event_body = event_write.add_mutually_exclusive_group()
212
+ event_body.add_argument("--body", help="event body, inline")
213
+ event_body.add_argument("--body-file", help="path to the event body in Markdown, or '-' to read stdin")
214
+ event_write.add_argument("--source-ref", help="provenance id, e.g. the debrief stem the event was folded from")
215
+ event_write.add_argument(
216
+ "--related", help="cross-references: comma-separated or a JSON array (e.g. 'PR #43,issue #10')"
217
+ )
218
+ event_write.add_argument("--supersedes", help="path of the event this entry overrides")
219
+ # The kernel script's home flag; accepted, unadvertised, through v0.1.x.
220
+ event_write.add_argument("--oacp-dir", dest="home", metavar="PATH", help=argparse.SUPPRESS)
221
+ event_write.add_argument(
222
+ "--dry-run", action="store_true", help="validate and compose the record, print it, and write nothing"
223
+ )
224
+ event_write.add_argument("--json", dest="json_output", action="store_true", help="emit a machine-readable result")
192
225
  return parser
193
226
 
194
227
 
@@ -203,23 +236,44 @@ def _max_chars(value: str) -> int:
203
236
 
204
237
 
205
238
  def _status(args: argparse.Namespace) -> int:
206
- readout = status.inspect(resolve_home(args.home), fetch=args.fetch)
207
- 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()))
208
251
  return readout.exit_code
209
252
 
210
253
 
211
254
  def _doctor(args: argparse.Namespace) -> int:
212
- 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
213
264
  if not home.is_dir():
214
- 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)
215
270
  return EXIT_FAILED
216
- categories = doctor.run_doctor(home)
217
- memory_lint = doctor.find_memory_lint()
271
+ categories, readout = doctor.diagnose(home)
218
272
  if args.json_output:
219
- 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))
220
274
  else:
221
275
  sys.stdout.write(doctor.report(categories, memory_lint=memory_lint))
222
- 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
223
277
 
224
278
 
225
279
  def _init(args: argparse.Namespace) -> int:
@@ -414,6 +468,85 @@ def _debrief_write(args: argparse.Namespace) -> int:
414
468
  return EXIT_OK
415
469
 
416
470
 
471
+ def _event_write(args: argparse.Namespace) -> int:
472
+ home = resolve_home(args.home).path
473
+ try:
474
+ body = _event_body(args)
475
+ except OSError as exc:
476
+ print(f"ERROR: cannot read body: {exc}", file=sys.stderr)
477
+ return EXIT_FAILED
478
+ except events.WriterError as exc:
479
+ print(f"ERROR: {exc}", file=sys.stderr)
480
+ return exc.code
481
+
482
+ try:
483
+ result = events.write_event(
484
+ home=home,
485
+ agent=args.agent,
486
+ project=args.project,
487
+ event_type=args.event_type,
488
+ slug=args.slug,
489
+ body=body,
490
+ source_ref=args.source_ref,
491
+ related=events.normalize_related(args.related) if args.related else None,
492
+ supersedes=args.supersedes,
493
+ dry_run=args.dry_run,
494
+ )
495
+ except events.WriterError as exc:
496
+ print(f"ERROR: {exc}", file=sys.stderr)
497
+ return exc.code
498
+ except OSError as exc:
499
+ print(f"ERROR: publication failed: {exc}", file=sys.stderr)
500
+ return 2
501
+
502
+ if args.json_output:
503
+ print(
504
+ json.dumps(
505
+ {
506
+ "path": str(result.path),
507
+ "status": result.status,
508
+ "created_at_utc": result.created_at_utc,
509
+ "date": result.created_at_utc[:10],
510
+ "agent": args.agent,
511
+ "project": args.project,
512
+ "type": args.event_type,
513
+ },
514
+ indent=2,
515
+ )
516
+ )
517
+ else:
518
+ print(f"{result.status}: {result.path}")
519
+ print(f"created_at_utc: {result.created_at_utc}")
520
+
521
+ if args.dry_run:
522
+ print("--- record preview (nothing was written) ---", file=sys.stderr)
523
+ sys.stderr.flush()
524
+ sys.stderr.buffer.write(result.record)
525
+ sys.stderr.buffer.flush()
526
+ return EXIT_OK
527
+
528
+
529
+ def _event_body(args: argparse.Namespace) -> bytes:
530
+ """The body bytes: ``--body-file`` (a path, or ``-`` for stdin), ``--body``, else piped stdin.
531
+
532
+ Trailing newlines are dropped, as the kernel script drops them; the record
533
+ closes the body with exactly one. A body file is read as text, so its
534
+ CRLF and CR line endings become LF (:func:`events.body_from_file`); stdin
535
+ and the inline body are taken as given, both as the script does.
536
+ """
537
+ if args.body_file is not None and args.body_file != "-":
538
+ return events.body_from_file(Path(args.body_file).expanduser().read_bytes())
539
+ if args.body_file is not None:
540
+ raw = sys.stdin.buffer.read()
541
+ elif args.body is not None:
542
+ raw = args.body.encode("utf-8")
543
+ elif not sys.stdin.isatty():
544
+ raw = sys.stdin.buffer.read()
545
+ else:
546
+ raise events.WriterError("no body provided: use --body, --body-file <path|->, or pipe the body to stdin", 1)
547
+ return raw.rstrip(b"\n")
548
+
549
+
417
550
  def _report(outcome: sync.Outcome) -> int:
418
551
  if outcome.lines:
419
552
  print("\n".join(outcome.lines), file=sys.stdout if outcome.ok else sys.stderr)
@@ -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:
@@ -444,20 +477,16 @@ def check_memory_sync(
444
477
  state = None
445
478
 
446
479
  if state is not None:
447
- if state.dirty:
448
- cat.add(
449
- "working-tree",
450
- Severity.warn,
451
- "working tree — DIRTY memory changes present",
452
- "Run `agent-memory push` or resolve changes manually",
453
- )
454
- else:
455
- cat.add("working-tree", Severity.ok, "working tree — clean")
480
+ _add_working_tree(cat, home, runner, state)
456
481
 
457
482
  text = f"sync state — {sync_state_text(state)}"
458
483
  if not state.has_remote:
459
484
  cat.add("sync-state", Severity.ok, text)
460
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")
461
490
  elif state.fetch_failed:
462
491
  cat.add("sync-state", Severity.warn, text, "Check network access and remote permissions")
463
492
  cat.add("remote", Severity.warn, "remote — not reachable", "Check network access and remote permissions")
@@ -531,7 +560,42 @@ def check_memory_sync(
531
560
  else:
532
561
  cat.add("memory-overlays", Severity.ok, f"memory .gitignore overlays — {len(overlays)} safe")
533
562
 
534
- return cat
563
+ fetched = state is not None and state.has_remote
564
+ return cat, prerequisites.assess(git, configured=True, state=state, fetched=fetched)
565
+
566
+
567
+ def _add_working_tree(cat: Category, home: Path, runner: Optional[GitRunner], state: GitState) -> None:
568
+ """The tree row, scoped like the counts above it.
569
+
570
+ Memory changes inside the allowlist are what ``push`` would publish and
571
+ read DIRTY. Changes anywhere else are named as outside the allowlist: not
572
+ memory content, but ``pull`` still refuses a tree that carries them.
573
+ """
574
+ if not state.dirty:
575
+ cat.add("working-tree", Severity.ok, "working tree — clean")
576
+ return
577
+ try:
578
+ changed = sync.changed_paths(home, runner)
579
+ except sync.SyncError as exc:
580
+ cat.add("working-tree", Severity.warn, f"working tree — DIRTY; the change readout failed: {exc}")
581
+ return
582
+ memory = [path for path in changed if layout.is_allowed_memory_path(path)]
583
+ elsewhere = [path for path in changed if not layout.is_allowed_memory_path(path)]
584
+ if memory:
585
+ text = f"working tree — DIRTY {len(memory)} memory change(s): {_summarize(memory)}"
586
+ if elsewhere:
587
+ text += f"; {len(elsewhere)} change(s) outside the memory allowlist"
588
+ cat.add("working-tree", Severity.warn, text, "Run `agent-memory push` or resolve changes manually")
589
+ elif elsewhere:
590
+ cat.add(
591
+ "working-tree",
592
+ Severity.warn,
593
+ f"working tree — {len(elsewhere)} change(s) outside the memory allowlist: {_summarize(elsewhere)}; "
594
+ "no memory change is pending, but memory pull refuses a dirty tree",
595
+ "Commit, stash or ignore them outside the memory tiers",
596
+ )
597
+ else:
598
+ cat.add("working-tree", Severity.ok, "working tree — clean")
535
599
 
536
600
 
537
601
  def enclosing_repository(home: Path, runner: Optional[GitRunner] = None) -> Optional[Path]:
@@ -542,10 +606,30 @@ def enclosing_repository(home: Path, runner: Optional[GitRunner] = None) -> Opti
542
606
  return root
543
607
 
544
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
+
545
626
  def sync_state_text(state: GitState) -> str:
546
627
  """One phrase for where the repository stands; ``status`` and ``doctor`` share it."""
547
628
  if not state.has_remote:
548
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}"
549
633
  if state.fetch_failed:
550
634
  return f"remote fetch failed: {state.fetch_output}"
551
635
  if not state.has_upstream: