agent-memory-cli 0.1.3__tar.gz → 0.1.4__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 (81) hide show
  1. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/CHANGELOG.md +11 -0
  2. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/PKG-INFO +1 -1
  3. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/__init__.py +1 -1
  4. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/doctor.py +9 -7
  5. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/startup.py +45 -2
  6. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/sync.py +178 -43
  7. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_doctor.py +19 -15
  8. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_startup.py +78 -0
  9. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_sync.py +166 -3
  10. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/.gitignore +0 -0
  11. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/LICENSE +0 -0
  12. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/Makefile +0 -0
  13. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/README.md +0 -0
  14. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/pyproject.toml +0 -0
  15. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/scripts/check_installed_wheel.py +0 -0
  16. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/scripts/release_notes.py +0 -0
  17. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/__main__.py +0 -0
  18. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/archive.py +0 -0
  19. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/cli.py +0 -0
  20. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/debrief.py +0 -0
  21. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/events.py +0 -0
  22. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/git_runner.py +0 -0
  23. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/home.py +0 -0
  24. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/layout.py +0 -0
  25. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/org.py +0 -0
  26. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/prerequisites.py +0 -0
  27. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/publication.py +0 -0
  28. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/__init__.py +0 -0
  29. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/claude.py +0 -0
  30. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/codex.py +0 -0
  31. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/common.py +0 -0
  32. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/setup/legacy.py +0 -0
  33. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/status.py +0 -0
  34. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/decisions.md +0 -0
  35. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/recent.md +0 -0
  36. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/org-memory/rules.md +0 -0
  37. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/project-memory/decision_log.md +0 -0
  38. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/project-memory/known_debt.md +0 -0
  39. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/project-memory/open_threads.md +0 -0
  40. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/project-memory/project_facts.md +0 -0
  41. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/templates/workflow/SKILL.md +0 -0
  42. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/src/agent_memory/workflow.py +0 -0
  43. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/expected.json +0 -0
  44. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md +0 -0
  45. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  46. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md +0 -0
  47. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md +0 -0
  48. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md +0 -0
  49. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md +0 -0
  50. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/empty_store/expected.json +0 -0
  51. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep +0 -0
  52. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.json +0 -0
  53. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md +0 -0
  54. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/staging_artifact/expected.json +0 -0
  55. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 +0 -0
  56. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  57. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/expected.json +0 -0
  58. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep +0 -0
  59. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md +0 -0
  60. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md +0 -0
  61. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md +0 -0
  62. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md +0 -0
  63. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/conftest.py +0 -0
  64. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/fixtures/setup/claude_settings_fleet.json +0 -0
  65. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/fixtures/setup/codex_hooks_kernel.json +0 -0
  66. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/canonical_memory_gitignore.txt +0 -0
  67. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/debrief_record.md +0 -0
  68. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/doctor_memory_0.4.5.json +0 -0
  69. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/doctor_memory_0.4.5.txt +0 -0
  70. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/golden/event_record.md +0 -0
  71. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_archive.py +0 -0
  72. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_cli.py +0 -0
  73. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_debrief.py +0 -0
  74. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_events.py +0 -0
  75. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_home.py +0 -0
  76. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_layout.py +0 -0
  77. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_org.py +0 -0
  78. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_prerequisites.py +0 -0
  79. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_setup.py +0 -0
  80. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/tests/test_status.py +0 -0
  81. {agent_memory_cli-0.1.3 → agent_memory_cli-0.1.4}/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.4 - 2026-10-26
8
+
9
+ ### Fixed
10
+
11
+ - `pull` and `push` sync one at a time per home and never abort a rebase they did not start ([sync](docs/commands.md#sync)).
12
+ - `pull` with only unpushed commits no longer also reports "already synced" ([sync](docs/commands.md#sync)).
13
+ - `startup --pull` no longer publishes a debrief or event another agent wrote ([startup](docs/commands.md#startup)).
14
+ - `pull` and `push` update past untracked files the incoming commits do not touch, so SessionStart keeps pulling ([sync](docs/commands.md#sync)).
15
+ - `doctor` on Python 3.14 reports a debrief record it cannot inspect as unreadable, not as an irregular file ([doctor](docs/commands.md#doctor)).
16
+ - `enable` starts a new memory repository on `main` even where git's `init.defaultBranch` is `master` ([sync](docs/commands.md#sync)).
17
+
7
18
  ## 0.1.3 - 2026-09-29
8
19
 
9
20
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-memory-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -4,6 +4,6 @@
4
4
 
5
5
  from .home import HomeError, HomeResolution, resolve_home
6
6
 
7
- __version__ = "0.1.3"
7
+ __version__ = "0.1.4"
8
8
 
9
9
  __all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
@@ -237,7 +237,7 @@ def check_org_memory(home: Path) -> Category:
237
237
  for name in sorted(dirnames):
238
238
  entry = current / name
239
239
  try:
240
- is_link = entry.is_symlink()
240
+ is_link = stat.S_ISLNK(os.lstat(entry).st_mode)
241
241
  except OSError as exc:
242
242
  walk_errors.append(f"{_relative(entry)}: {exc.__class__.__name__}")
243
243
  continue
@@ -258,16 +258,18 @@ def check_org_memory(home: Path) -> Category:
258
258
  staging.append(rel)
259
259
  continue
260
260
  # The namespace holds regular files reached without following links;
261
- # a classification failure surfaces, never raises.
261
+ # a classification failure surfaces, never raises. lstat, not the
262
+ # pathlib predicates: since Python 3.14 those answer False on any
263
+ # OSError, so a record that could not be inspected would read as irregular.
262
264
  try:
263
- if file_path.is_symlink():
264
- irregular.append(f"{rel} (symlink)")
265
- continue
266
- regular = file_path.is_file()
265
+ mode = os.lstat(file_path).st_mode
267
266
  except OSError as exc:
268
267
  walk_errors.append(f"{rel}: {exc.__class__.__name__}")
269
268
  continue
270
- if not regular:
269
+ if stat.S_ISLNK(mode):
270
+ irregular.append(f"{rel} (symlink)")
271
+ continue
272
+ if not stat.S_ISREG(mode):
271
273
  irregular.append(f"{rel} (not a regular file)")
272
274
  continue
273
275
  total += 1
@@ -14,6 +14,12 @@ files, both from the layout table; ``events/``, ``debriefs/`` and
14
14
  ``archive/`` are excluded), and the rendered text is cut at a character
15
15
  budget with a notice, so a hook can never flood a session.
16
16
 
17
+ With ``--pull`` the session's runtime is the publishing identity, and a
18
+ path another agent wrote is not its to publish: a debrief names its agent in
19
+ its filename and an event in its frontmatter, so either one, when it names
20
+ someone else, is held for that agent's own session. Files that carry no
21
+ author (the curated org files, the project tier) publish as before.
22
+
17
23
  Output shapes: ``--json`` is the manifest itself, ``schema_version`` first;
18
24
  the default is what the runtime's hook expects on stdout: plain text for
19
25
  claude, whose session start takes stdout as context, and the hook JSON
@@ -28,8 +34,9 @@ import stat
28
34
  from pathlib import Path
29
35
  from typing import Any, Dict, List, Optional, Sequence, Tuple
30
36
 
31
- from . import layout, sync
37
+ from . import debrief, events, layout, sync
32
38
  from .git_runner import GitRunner, run_git
39
+ from .publication import WriterError
33
40
 
34
41
  SCHEMA_VERSION = 1
35
42
  RUNTIME_CLAUDE = "claude"
@@ -46,6 +53,8 @@ RESULT_OK = "ok"
46
53
  RESULT_DEGRADED = "degraded"
47
54
  PULL_NOT_REQUESTED = "not_requested"
48
55
  PULL_ERROR = "error"
56
+ #: The most of an event the author check reads; the frontmatter sits at its head.
57
+ _EVENT_HEAD_BYTES = 64 * 1024
49
58
 
50
59
 
51
60
  def build_manifest(
@@ -161,7 +170,7 @@ def _sync(
161
170
  pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []}
162
171
  if pull:
163
172
  try:
164
- outcome = sync.pull(home, agent=runtime, runner=runner)
173
+ outcome = sync.pull(home, agent=runtime, runner=runner, hold=lambda path: _held(home, path, runtime))
165
174
  pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)}
166
175
  except (sync.SyncError, OSError) as exc:
167
176
  pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]}
@@ -176,6 +185,40 @@ def _sync(
176
185
  return info, pull_info
177
186
 
178
187
 
188
+ def _held(home: Path, relative: str, runtime: str) -> bool:
189
+ """Whether ``relative`` names an author other than ``runtime``; no author named means not held."""
190
+ author = author_of(home, relative)
191
+ return author is not None and author != runtime
192
+
193
+
194
+ def author_of(home: Path, relative: str) -> Optional[str]:
195
+ """The agent a memory path names as its author, or ``None`` when it names none.
196
+
197
+ A debrief, ``org-memory/debriefs/<project>/<YYYY>/<MM>/<YYYYMMDD>-<agent>-<session>.md``,
198
+ names it in the filename: the session id has no dash, so the last dash
199
+ splits the agent from it whatever dashes the agent's own name carries. An
200
+ event, ``org-memory/events/<name>.md``, names it in the frontmatter
201
+ ``agent:`` field. Anything else, and anything that does not parse, names
202
+ no one.
203
+ """
204
+ parts = relative.split("/")
205
+ if parts[:2] == [layout.ORG.pattern, "debriefs"] and len(parts) == 6 and parts[5].endswith(".md"):
206
+ date, _, rest = parts[5][: -len(".md")].partition("-")
207
+ agent, _, session = rest.rpartition("-")
208
+ if len(date) == 8 and date.isdigit() and debrief.SESSION_RE.match(session) and debrief.AGENT_RE.match(agent):
209
+ return agent
210
+ return None
211
+ if parts[:2] == [layout.ORG.pattern, events.EVENTS_DIR] and len(parts) == 3 and parts[2].endswith(".md"):
212
+ try:
213
+ with open(home / relative, "rb") as handle:
214
+ frontmatter, _ = debrief.split_record(handle.read(_EVENT_HEAD_BYTES))
215
+ except (OSError, ValueError, WriterError):
216
+ return None
217
+ agent = frontmatter.get("agent", "")
218
+ return agent if debrief.AGENT_RE.match(agent) else None
219
+ return None
220
+
221
+
179
222
  # --- rendering --------------------------------------------------------------
180
223
 
181
224
 
@@ -29,7 +29,17 @@ The network verbs (fetch, pull, push, clone) run under a 30 s timeout.
29
29
  ``push`` and ``pull --agent`` commit allowlisted changes before fetching,
30
30
  rebase when behind, then deliver. A clean ``pull`` without an agent updates
31
31
  and reports unpushed commits. Conflicts abort the rebase and keep local
32
- commits; rejected delivery also leaves them local.
32
+ commits; rejected delivery also leaves them local. ``pull`` takes a hold
33
+ predicate from its caller: a selected path it holds is left uncommitted for
34
+ whoever wrote it, which keeps the engine content-blind when the predicate
35
+ has to read a file to name its author. An update never moves a tracked
36
+ change, but an untracked path the incoming commits do not touch rides
37
+ through the fast-forward or rebase in place.
38
+
39
+ ``pull`` and ``push`` hold an exclusive lock under the git directory, so one
40
+ of them syncs a home at a time and a second waits for the first. A rebase
41
+ found in progress is never this verb's to abort: one is refused before
42
+ anything is written, and checked for again just before the verb's own rebase.
33
43
  """
34
44
 
35
45
  from __future__ import annotations
@@ -39,18 +49,34 @@ import os
39
49
  import shutil
40
50
  import socket
41
51
  import tempfile
52
+ import time
53
+ from contextlib import contextmanager
42
54
  from dataclasses import dataclass
43
55
  from pathlib import Path
44
- from typing import Iterable, List, Mapping, Optional, Sequence, Tuple
56
+ from typing import Callable, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple
45
57
 
46
58
  from . import layout
47
59
  from .git_runner import GitResult, GitRunner, run_git
48
60
 
61
+ try:
62
+ import fcntl
63
+ except ImportError: # pragma: no cover - no flock here; the in-progress checks still apply
64
+ fcntl = None # type: ignore[assignment]
65
+
49
66
  MARKER_FILE = layout.MARKER_FILE
50
67
  GITIGNORE_FILE = layout.GITIGNORE_FILE
51
68
  MARKER_TEXT = "agent-memory sync repository. Remove this file to disable syncing locally.\n"
52
69
  NETWORK_TIMEOUT_SECONDS = 30
53
70
  DEFAULT_REMOTE = "origin"
71
+ #: The branch a new memory repository starts on, whatever git's local ``init.defaultBranch`` says.
72
+ DEFAULT_BRANCH = "main"
73
+
74
+ #: The sync lock, a file under the git directory that ``pull`` and ``push`` hold with flock.
75
+ LOCK_FILE = "agent-memory-sync.lock"
76
+ #: How long a verb waits for another sync of the same home: the longest a holder can
77
+ #: run is its four network-bounded steps (fetch, rebase, abort, push).
78
+ LOCK_WAIT_SECONDS: float = 4 * NETWORK_TIMEOUT_SECONDS
79
+ _LOCK_POLL_SECONDS = 0.1
54
80
 
55
81
  #: The agent name a commit is published under, when the caller does not pass one.
56
82
  ENV_AGENT = "AGENT_MEMORY_AGENT"
@@ -148,6 +174,8 @@ class Outcome:
148
174
  #: Paths that were staged outside the selection and were left exactly as found.
149
175
  preserved: Tuple[str, ...] = ()
150
176
  receipt: Optional[GitignoreReceipt] = None
177
+ #: Selected paths the caller's hold predicate kept out of the commit, home-relative.
178
+ held: Tuple[str, ...] = ()
151
179
 
152
180
 
153
181
  # --- marker and ignore file -------------------------------------------------
@@ -293,6 +321,10 @@ def init(
293
321
  result = _git(home, ["init", "--quiet"], runner)
294
322
  if not result.ok:
295
323
  raise SyncError(f"git init failed: {result.output}")
324
+ # Name the unborn branch here rather than with --initial-branch, which needs git 2.28.
325
+ result = _git(home, ["symbolic-ref", "HEAD", f"refs/heads/{DEFAULT_BRANCH}"], runner)
326
+ if not result.ok:
327
+ raise SyncError(f"git symbolic-ref failed: {result.output}")
296
328
  receipt = ensure_gitignore(home)
297
329
  lines = receipt.lines()
298
330
  if write_marker(home):
@@ -355,37 +387,44 @@ def pull(
355
387
  agent: Optional[str] = None,
356
388
  runner: Optional[GitRunner] = None,
357
389
  env: Optional[Mapping[str, str]] = None,
390
+ hold: Optional[Callable[[str], bool]] = None,
358
391
  ) -> Outcome:
359
392
  """Update the home, publishing dirty memory only with an explicit identity.
360
393
 
361
394
  Clean pulls keep their read-mostly behavior: fast-forward, or rebase local
362
395
  commits and report ahead. An explicit agent permits dirty publication/delivery;
363
- SessionStart supplies its runtime name through this same entry point.
396
+ SessionStart supplies its runtime name through this same entry point, with a
397
+ ``hold`` predicate that keeps another agent's pending paths out of the commit.
364
398
  """
365
399
  home = _home(home)
366
400
  if not is_configured(home):
367
401
  return Outcome("not_configured", True)
368
402
  _require_repo(home, runner)
369
- if _operation_in_progress(home, runner):
370
- return Outcome("operation_in_progress", False, ("memory pull: a git operation is in progress; finish it first.",))
371
- if changed_paths(home, runner):
372
- if agent:
373
- return _sync_changes(home, agent=agent, runner=runner, env=env)
374
- return Outcome("dirty", False, ("memory pull: uncommitted changes present; pass --agent NAME to commit and sync.",))
375
- state = git_state(home, runner=runner, fetch=True)
376
- if not state.has_remote:
377
- return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",))
378
- if state.fetch_failed:
379
- return _fetch_failure(state, "memory pull")
380
- if not state.has_upstream:
381
- return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",))
382
- updated = _update(home, state, runner)
383
- if not updated.ok:
403
+ with _sync_lock(home, runner, "memory pull") as busy:
404
+ if busy:
405
+ return busy
406
+ if _operation_in_progress(home, runner):
407
+ return Outcome("operation_in_progress", False, ("memory pull: a git operation is in progress; finish it first.",))
408
+ if changed_paths(home, runner):
409
+ if agent:
410
+ return _sync_changes(home, agent=agent, runner=runner, env=env, hold=hold)
411
+ return Outcome("dirty", False, ("memory pull: uncommitted changes present; pass --agent NAME to commit and sync.",))
412
+ state = git_state(home, runner=runner, fetch=True)
413
+ if not state.has_remote:
414
+ return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",))
415
+ if state.fetch_failed:
416
+ return _fetch_failure(state, "memory pull")
417
+ if not state.has_upstream:
418
+ return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",))
419
+ updated = _update(home, state, runner)
420
+ if not updated.ok:
421
+ return updated
422
+ current = git_state(home, runner=runner, fetch=False) if state.behind else state
423
+ if current.ahead:
424
+ # Nothing came down unless it was behind; "already synced" would contradict the count.
425
+ pulled = updated.lines if state.behind else ()
426
+ return Outcome("ahead", True, (*pulled, f"memory pull: {current.ahead} unpushed commit(s); use push to deliver."))
384
427
  return updated
385
- current = git_state(home, runner=runner, fetch=False) if state.behind else state
386
- if current.ahead:
387
- return Outcome("ahead", True, (*updated.lines, f"memory pull: {current.ahead} unpushed commit(s); use push to deliver."))
388
- return updated
389
428
 
390
429
 
391
430
  def push(
@@ -400,9 +439,49 @@ def push(
400
439
  if not is_configured(home):
401
440
  return Outcome("not_configured", True)
402
441
  _require_repo(home, runner)
403
- if _operation_in_progress(home, runner):
404
- return Outcome("operation_in_progress", False, ("memory push: a git operation is in progress; finish it first.",))
405
- return _sync_changes(home, agent=agent, runner=runner, env=env)
442
+ with _sync_lock(home, runner, "memory push") as busy:
443
+ if busy:
444
+ return busy
445
+ if _operation_in_progress(home, runner):
446
+ return Outcome("operation_in_progress", False, ("memory push: a git operation is in progress; finish it first.",))
447
+ return _sync_changes(home, agent=agent, runner=runner, env=env)
448
+
449
+
450
+ @contextmanager
451
+ def _sync_lock(home: Path, runner: Optional[GitRunner], verb: str) -> Iterator[Optional[Outcome]]:
452
+ """Hold the home's sync lock; yield ``None``, or the outcome to return when another sync kept it.
453
+
454
+ flock belongs to the open file, so the kernel releases it when the holder exits,
455
+ however it exits: a crashed sync never leaves the home locked. The file stays.
456
+ """
457
+ if fcntl is None: # pragma: no cover
458
+ yield None
459
+ return
460
+ result = _git(home, ["rev-parse", "--git-path", LOCK_FILE], runner)
461
+ if not result.ok or not result.stdout.strip():
462
+ raise SyncError(f"cannot locate the git directory for the sync lock: {result.output}")
463
+ fd = os.open(home / result.stdout.strip(), os.O_RDWR | os.O_CREAT, 0o644)
464
+ try:
465
+ if _flock_within(fd, LOCK_WAIT_SECONDS):
466
+ yield None
467
+ else:
468
+ yield Outcome("sync_in_progress", False, (
469
+ f"{verb}: another agent-memory sync held this home for {LOCK_WAIT_SECONDS:g}s; nothing was changed.",
470
+ ))
471
+ finally:
472
+ os.close(fd)
473
+
474
+
475
+ def _flock_within(fd: int, seconds: float) -> bool:
476
+ deadline = time.monotonic() + seconds
477
+ while True:
478
+ try:
479
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
480
+ return True
481
+ except BlockingIOError:
482
+ if time.monotonic() >= deadline:
483
+ return False
484
+ time.sleep(_LOCK_POLL_SECONDS)
406
485
 
407
486
 
408
487
  def _operation_in_progress(home: Path, runner: Optional[GitRunner]) -> bool:
@@ -418,9 +497,14 @@ def _operation_in_progress(home: Path, runner: Optional[GitRunner]) -> bool:
418
497
 
419
498
 
420
499
  def _sync_changes(
421
- home: Path, *, agent: Optional[str], runner: Optional[GitRunner], env: Optional[Mapping[str, str]]
500
+ home: Path,
501
+ *,
502
+ agent: Optional[str],
503
+ runner: Optional[GitRunner],
504
+ env: Optional[Mapping[str, str]],
505
+ hold: Optional[Callable[[str], bool]] = None,
422
506
  ) -> Outcome:
423
- published = _publish(home, agent=agent, runner=runner, env=env)
507
+ published = _publish(home, agent=agent, runner=runner, env=env, hold=hold)
424
508
  if not published.ok:
425
509
  return published
426
510
  # Commit before fetch: even a network failure leaves a recoverable local commit.
@@ -428,12 +512,14 @@ def _sync_changes(
428
512
  lines = list(published.lines)
429
513
  if not state.has_remote:
430
514
  lines.append("memory push: no remote configured; the commit remains local.")
431
- return Outcome("local_only", True, tuple(lines), published.committed, published.preserved)
515
+ return Outcome("local_only", True, tuple(lines), published.committed, published.preserved,
516
+ held=published.held)
432
517
  if not state.fetch_failed and state.has_upstream and state.behind:
433
518
  updated = _update(home, state, runner)
434
519
  lines.extend(updated.lines)
435
520
  if not updated.ok:
436
- return Outcome(updated.status, False, tuple(lines), published.committed, published.preserved)
521
+ return Outcome(updated.status, False, tuple(lines), published.committed, published.preserved,
522
+ held=published.held)
437
523
  state = git_state(home, runner=runner, fetch=False)
438
524
  return _deliver(home, published, state, lines, runner)
439
525
 
@@ -442,9 +528,10 @@ def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome
442
528
  if not state.behind:
443
529
  return Outcome("up_to_date", True, ("memory pull: already synced.",))
444
530
  remaining = changed_paths(home, runner)
445
- if remaining:
531
+ blocking = _blocking_paths(home, state, remaining, runner)
532
+ if blocking:
446
533
  return Outcome("dirty", False, (
447
- "memory pull: cannot update with remaining uncommitted paths; left untouched: " + ", ".join(remaining),
534
+ "memory pull: cannot update with remaining uncommitted paths; left untouched: " + ", ".join(blocking),
448
535
  ))
449
536
  if not state.ahead:
450
537
  result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
@@ -452,20 +539,24 @@ def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome
452
539
  return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",))
453
540
  if not result.ok:
454
541
  return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",))
455
- return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",))
542
+ return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).", *_kept_lines(remaining)))
456
543
  merges = _git(home, ["rev-list", "--merges", f"{state.upstream}..HEAD"], runner)
457
544
  if not merges.ok or merges.stdout.strip():
458
545
  return Outcome("diverged", False, (
459
546
  "memory pull: local merge commits cannot be safely rebased automatically; resolve manually."
460
547
  if merges.ok else f"memory pull: cannot inspect local history: {merges.output}",
461
548
  ))
549
+ # The sync lock keeps every other agent-memory verb out; this catches a rebase another
550
+ # tool began after the precheck, which a failed rebase of ours would otherwise abort.
551
+ if _operation_in_progress(home, runner):
552
+ return Outcome("operation_in_progress", False, ("memory pull: a git operation began during the sync; finish it first.",))
462
553
  # Rebase exactly the fetched upstream. Do not stash unrelated edits or reuse
463
554
  # a recorded conflict resolution: a conflicting same-path add must surface.
464
555
  result = _git(home, ["-c", "rerere.enabled=false", "-c", "rebase.updateRefs=false",
465
556
  "rebase", "--no-autostash", state.upstream],
466
557
  runner, timeout=NETWORK_TIMEOUT_SECONDS)
467
558
  if result.ok:
468
- return Outcome("synced", True, ("memory pull: rebased local commits onto upstream.",))
559
+ return Outcome("synced", True, ("memory pull: rebased local commits onto upstream.", *_kept_lines(remaining)))
469
560
  conflicts = _git(home, ["diff", "--name-only", "--diff-filter=U", "-z"], runner)
470
561
  paths = [path for path in conflicts.stdout.split("\0") if path] if conflicts.ok else []
471
562
  if _operation_in_progress(home, runner):
@@ -482,6 +573,38 @@ def _update(home: Path, state: GitState, runner: Optional[GitRunner]) -> Outcome
482
573
  ))
483
574
 
484
575
 
576
+ def _blocking_paths(home: Path, state: GitState, remaining: Sequence[str], runner: Optional[GitRunner]) -> List[str]:
577
+ """The uncommitted paths an update cannot carry: every tracked change, and any untracked
578
+ path that an incoming commit adds, changes or removes, itself or as a parent directory."""
579
+ if not remaining:
580
+ return []
581
+ untracked = set(_untracked_paths(home, runner))
582
+ if not untracked:
583
+ return list(remaining)
584
+ incoming = _git(home, ["diff", "--name-only", "-z", f"HEAD...{state.upstream}"], runner)
585
+ if not incoming.ok:
586
+ return list(remaining)
587
+ touched = [path for path in incoming.stdout.split("\0") if path]
588
+ return [path for path in remaining if path not in untracked or any(_overlaps(path, other) for other in touched)]
589
+
590
+
591
+ def _overlaps(path: str, other: str) -> bool:
592
+ return path == other or path.startswith(f"{other}/") or other.startswith(f"{path}/")
593
+
594
+
595
+ def _untracked_paths(home: Path, runner: Optional[GitRunner]) -> List[str]:
596
+ result = _git(home, ["ls-files", "--others", "--exclude-standard", "-z"], runner)
597
+ if not result.ok:
598
+ raise SyncError(f"git ls-files failed: {result.output}")
599
+ return [path for path in result.stdout.split("\0") if path]
600
+
601
+
602
+ def _kept_lines(remaining: Sequence[str]) -> Tuple[str, ...]:
603
+ if not remaining:
604
+ return ()
605
+ return (f"memory pull: {len(remaining)} untracked path(s) left in place: {', '.join(remaining)}",)
606
+
607
+
485
608
  def disable(home: Path) -> Outcome:
486
609
  """Remove the marker; the repository and its history stay in place."""
487
610
  home = _home(home)
@@ -559,6 +682,7 @@ def _publish(
559
682
  agent: Optional[str],
560
683
  runner: Optional[GitRunner],
561
684
  env: Optional[Mapping[str, str]],
685
+ hold: Optional[Callable[[str], bool]] = None,
562
686
  ) -> Outcome:
563
687
  selected, outside = select_paths(home, runner)
564
688
  if outside:
@@ -571,8 +695,14 @@ def _publish(
571
695
  f"{', '.join(outside)}",
572
696
  ),
573
697
  )
574
- preserved = tuple(sorted(set(staged_paths(home, runner)) - set(selected)))
698
+ held = tuple(path for path in selected if hold is not None and hold(path))
699
+ selected = [path for path in selected if path not in held]
700
+ preserved = tuple(sorted(set(staged_paths(home, runner)) - set(selected) - set(held)))
575
701
  lines: List[str] = []
702
+ if held:
703
+ lines.append(
704
+ f"memory publish: {len(held)} path(s) another agent wrote left for it to publish: {', '.join(held)}"
705
+ )
576
706
  if preserved:
577
707
  lines.append(
578
708
  f"memory publish: {len(preserved)} staged path(s) outside the allowlist left staged and "
@@ -580,22 +710,22 @@ def _publish(
580
710
  )
581
711
  if not selected:
582
712
  lines.append("memory publish: no memory changes to commit.")
583
- return Outcome("nothing_to_commit", True, tuple(lines), (), preserved)
713
+ return Outcome("nothing_to_commit", True, tuple(lines), (), preserved, held=held)
584
714
 
585
715
  message = commit_message(len(selected), resolve_agent(agent, env))
586
716
  with _pathspec_file(selected) as pathspec:
587
717
  added = _git(home, ["--literal-pathspecs", "add", *_from_file(pathspec)], runner)
588
718
  if not added.ok:
589
- return Outcome("commit_failed", False, (*lines, f"memory publish: git add failed: {added.output}"))
719
+ return Outcome("commit_failed", False, (*lines, f"memory publish: git add failed: {added.output}"), held=held)
590
720
  committed = _git(home, ["--literal-pathspecs", "commit", "--quiet", "-m", message, *_from_file(pathspec)], runner)
591
721
  if not committed.ok:
592
- return Outcome("commit_failed", False, (*lines, f"memory publish: git commit failed: {committed.output}"))
722
+ return Outcome("commit_failed", False, (*lines, f"memory publish: git commit failed: {committed.output}"), held=held)
593
723
  published = committed_paths(home, runner)
594
724
  stray = sorted(path for path in published if not layout.is_allowed_memory_path(path))
595
725
  if stray:
596
726
  raise SyncError(f"memory publish: HEAD commits paths outside the allowlist: {', '.join(stray)}")
597
727
  lines.append(f"memory publish: committed {len(published)} file(s).")
598
- return Outcome("committed", True, tuple(lines), tuple(published), preserved)
728
+ return Outcome("committed", True, tuple(lines), tuple(published), preserved, held=held)
599
729
 
600
730
 
601
731
  def _deliver(
@@ -611,10 +741,12 @@ def _deliver(
611
741
  return _helper_blocked(lines, published, receipt)
612
742
  failure = _fetch_failure(state, "memory push")
613
743
  lines.append(f"{failure.lines[0]} The commit remains local.")
614
- return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt)
744
+ return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt,
745
+ held=published.held)
615
746
  if state.has_upstream and not state.ahead:
616
747
  lines.append("memory push: remote already up to date.")
617
- return Outcome("up_to_date", True, tuple(lines), published.committed, published.preserved, receipt)
748
+ return Outcome("up_to_date", True, tuple(lines), published.committed, published.preserved, receipt,
749
+ held=published.held)
618
750
  result = _push_remote(home, state, runner)
619
751
  if not result.ok:
620
752
  if credential_helper_blocked(result.output):
@@ -622,9 +754,11 @@ def _deliver(
622
754
  what = "timed out" if result.timed_out else "was rejected"
623
755
  lines.append(f"memory push: the push {what}; the commit remains local. {result.output}".rstrip())
624
756
  status = "push_timed_out" if result.timed_out else "push_failed"
625
- return Outcome(status, False, tuple(lines), published.committed, published.preserved, receipt)
757
+ return Outcome(status, False, tuple(lines), published.committed, published.preserved, receipt,
758
+ held=published.held)
626
759
  lines.append("memory push: delivered to the remote.")
627
- return Outcome("pushed", True, tuple(lines), published.committed, published.preserved, receipt)
760
+ return Outcome("pushed", True, tuple(lines), published.committed, published.preserved, receipt,
761
+ held=published.held)
628
762
 
629
763
 
630
764
  def _push_remote(home: Path, state: GitState, runner: Optional[GitRunner]) -> GitResult:
@@ -656,7 +790,8 @@ def _helper_blocked(
656
790
  ) -> Outcome:
657
791
  lines.append(_HELPER_BLOCKED_LINE)
658
792
  return Outcome(
659
- "credential_helper_blocked", False, tuple(lines), published.committed, published.preserved, receipt
793
+ "credential_helper_blocked", False, tuple(lines), published.committed, published.preserved, receipt,
794
+ held=published.held,
660
795
  )
661
796
 
662
797
 
@@ -268,37 +268,37 @@ def test_a_store_that_cannot_be_inspected_is_an_error_not_uninitialized(tmp_path
268
268
 
269
269
 
270
270
  def test_directory_classification_failure_surfaces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
271
- # An is_symlink failure on a directory entry is reported, never raised.
271
+ # An lstat failure on a directory entry is reported, never raised.
272
272
  root = tmp_path / "home"
273
273
  root.mkdir()
274
274
  _write_debrief(root)
275
- real_is_symlink = Path.is_symlink
275
+ real_lstat = os.lstat
276
276
 
277
- def flaky(self: Path) -> bool:
278
- if self.name == "demo-project":
279
- raise PermissionError(13, "Permission denied", str(self))
280
- return real_is_symlink(self)
277
+ def flaky(path: object, *args: object, **kwargs: object) -> os.stat_result:
278
+ if isinstance(path, (str, os.PathLike)) and Path(path).name == "demo-project":
279
+ raise PermissionError(13, "Permission denied", str(path))
280
+ return real_lstat(path, *args, **kwargs)
281
281
 
282
- monkeypatch.setattr(Path, "is_symlink", flaky)
282
+ monkeypatch.setattr(os, "lstat", flaky)
283
283
  cat = check_org_memory(root)
284
284
  assert ("debriefs-unreadable", Severity.error) in _rows(cat)
285
285
  assert not any(r.name == "debriefs-layout" and "empty store" in r.message for r in cat.results)
286
286
 
287
287
 
288
288
  def test_record_classification_failure_surfaces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
289
- # A stat failure while classifying a record lands in the unreadable row; other records still pass.
289
+ # An lstat failure while classifying a record lands in the unreadable row; other records still pass.
290
290
  root = tmp_path / "home"
291
291
  root.mkdir()
292
292
  _write_debrief(root)
293
293
  victim = _write_debrief(root, "20260825-bob-77aa88bb.md")
294
- real_stat = Path.stat
294
+ real_lstat = os.lstat
295
295
 
296
- def flaky(self: Path, *args: object, **kwargs: object) -> os.stat_result:
297
- if self.name == victim.name:
298
- raise PermissionError(13, "Permission denied", str(self))
299
- return real_stat(self, *args, **kwargs)
296
+ def flaky(path: object, *args: object, **kwargs: object) -> os.stat_result:
297
+ if isinstance(path, (str, os.PathLike)) and Path(path).name == victim.name:
298
+ raise PermissionError(13, "Permission denied", str(path))
299
+ return real_lstat(path, *args, **kwargs)
300
300
 
301
- monkeypatch.setattr(Path, "stat", flaky)
301
+ monkeypatch.setattr(os, "lstat", flaky)
302
302
  rows = _org_rows(root)
303
303
  assert ("debriefs-unreadable", Severity.error) in rows
304
304
  assert ("debriefs-layout", Severity.ok) in rows
@@ -836,7 +836,11 @@ def test_report_points_at_memory_lint_only_when_it_is_on_path(tmp_path: Path) ->
836
836
  assert doctor.to_json(categories, memory_lint="/opt/bin/memory-lint")["memory_lint"] == "/opt/bin/memory-lint"
837
837
 
838
838
 
839
- def test_cli_exit_codes_follow_the_error_rows(tmp_path: Path, git_env: None, capsys: pytest.CaptureFixture[str]) -> None:
839
+ def test_cli_exit_codes_follow_the_error_rows(
840
+ tmp_path: Path, git_env: None, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
841
+ ) -> None:
842
+ # The verdict is the last line only without the memory-lint pointer, so pin the lookup off the host's PATH.
843
+ monkeypatch.setattr(doctor, "find_memory_lint", lambda: None)
840
844
  home, _ = synced_home(tmp_path)
841
845
  assert main(["doctor", "--home", str(home)]) == 0
842
846
  assert capsys.readouterr().out.rstrip("\n").endswith("No issues found.")
@@ -25,6 +25,7 @@ from agent_memory.startup import (
25
25
  MISSING,
26
26
  READABLE,
27
27
  UNREADABLE,
28
+ author_of,
28
29
  build_manifest,
29
30
  render_codex_hook,
30
31
  render_text,
@@ -404,3 +405,80 @@ def test_session_start_resolves_dirty_behind_home(home: Path, tmp_path: Path, gi
404
405
  assert git("status", "--porcelain", cwd=home) == ""
405
406
  assert git("log", "-1", "--format=%s", cwd=home).startswith(f"memory: {runtime}@")
406
407
  assert git("--git-dir", str(remote), "show", "main:org-memory/events/local.md", cwd=tmp_path) == "local content"
408
+
409
+
410
+ @pytest.mark.parametrize(
411
+ ("relative", "author"),
412
+ [
413
+ ("org-memory/debriefs/demo/2026/10/20261018-claude-ab12cd34.md", "claude"),
414
+ ("org-memory/debriefs/demo/2026/10/20261018-bob-ops-9f00aa11.md", "bob-ops"),
415
+ ("org-memory/debriefs/demo/2026/10/20261018-claude-AB12.md", None),
416
+ ("org-memory/debriefs/demo/20261018-claude-ab12cd34.md", None),
417
+ ("org-memory/events/20261018-120000-peer-rule.md", "codex"),
418
+ ("org-memory/events/20261018-120001-no-frontmatter.md", None),
419
+ ("org-memory/events/20261018-120002-missing.md", None),
420
+ ("org-memory/recent.md", None),
421
+ ("projects/demo/memory/open_threads.md", None),
422
+ ],
423
+ )
424
+ def test_author_of_reads_debrief_names_and_event_frontmatter(home: Path, relative: str, author: str) -> None:
425
+ from conftest import write
426
+ write(home / "org-memory/events/20261018-120000-peer-rule.md", "---\ncreated_at_utc: x\nagent: codex\n---\n\nbody\n")
427
+ write(home / "org-memory/events/20261018-120001-no-frontmatter.md", "just text\n")
428
+ assert author_of(home, relative) == author
429
+
430
+
431
+ @pytest.mark.parametrize("runtime", ["claude", "codex"])
432
+ def test_session_start_holds_another_agents_paths_and_still_pulls(
433
+ home: Path, tmp_path: Path, git_env: None, runtime: str
434
+ ) -> None:
435
+ # Synthetic files and identities only.
436
+ from conftest import git, write
437
+ peer_name = "codex" if runtime == "claude" else "claude"
438
+ remote = tmp_path / "remote.git"
439
+ git("init", "--bare", "--quiet", str(remote), cwd=tmp_path)
440
+ assert sync.init(home, remote=str(remote)).ok
441
+ peer = tmp_path / "peer"
442
+ git("clone", "--quiet", str(remote), str(peer), cwd=tmp_path)
443
+ write(peer / "org-memory/events/upstream.md", "upstream content\n")
444
+ git("add", "org-memory/events/upstream.md", cwd=peer)
445
+ git("commit", "-qm", "peer addition", cwd=peer)
446
+ git("push", cwd=peer)
447
+
448
+ peer_debrief = f"org-memory/debriefs/demo/2026/10/20261018-{peer_name}-ab12cd34.md"
449
+ peer_event = "org-memory/events/20261018-120000-peer-rule.md"
450
+ own_debrief = f"org-memory/debriefs/demo/2026/10/20261018-{runtime}-ef56ab78.md"
451
+ write(home / peer_debrief, "peer debrief\n")
452
+ write(home / peer_event, f"---\ncreated_at_utc: x\nagent: {peer_name}\n---\n\nbody\n")
453
+ write(home / own_debrief, "own debrief\n")
454
+ write(home / "projects/demo/memory/open_threads.md", "unattributed edit\n")
455
+
456
+ manifest = build_manifest(home, runtime=runtime, project="demo", pull=True)
457
+ assert manifest["pull"]["status"] == "pushed" and manifest["pull"]["ok"], manifest["pull"]
458
+ assert any("another agent wrote left for it to publish" in line for line in manifest["pull"]["lines"])
459
+ assert (home / "org-memory/events/upstream.md").read_text() == "upstream content\n"
460
+ assert sorted(git("ls-files", "--others", "--exclude-standard", cwd=home).splitlines()) == sorted([peer_debrief, peer_event])
461
+ published = git("--git-dir", str(remote), "ls-tree", "-r", "--name-only", "main", cwd=tmp_path).splitlines()
462
+ assert own_debrief in published and "projects/demo/memory/open_threads.md" in published
463
+ assert peer_debrief not in published and peer_event not in published
464
+
465
+
466
+ def test_session_start_with_only_held_paths_still_fast_forwards(home: Path, tmp_path: Path, git_env: None) -> None:
467
+ from conftest import git, write
468
+ remote = tmp_path / "remote.git"
469
+ git("init", "--bare", "--quiet", str(remote), cwd=tmp_path)
470
+ assert sync.init(home, remote=str(remote)).ok
471
+ peer = tmp_path / "peer"
472
+ git("clone", "--quiet", str(remote), str(peer), cwd=tmp_path)
473
+ write(peer / "org-memory/events/upstream.md", "upstream content\n")
474
+ git("add", "org-memory/events/upstream.md", cwd=peer)
475
+ git("commit", "-qm", "peer addition", cwd=peer)
476
+ git("push", cwd=peer)
477
+ held = "org-memory/debriefs/demo/2026/10/20261018-codex-ab12cd34.md"
478
+ write(home / held, "peer debrief\n")
479
+ head = git("rev-parse", "HEAD", cwd=home)
480
+
481
+ manifest = build_manifest(home, runtime="claude", project="demo", pull=True)
482
+ assert manifest["pull"]["ok"] and manifest["result"] == "ok", manifest["pull"]
483
+ assert git("rev-parse", "HEAD", cwd=home) == git("--git-dir", str(remote), "rev-parse", "main", cwd=tmp_path) != head
484
+ assert git("status", "--porcelain", "--untracked-files=all", cwd=home) == f"?? {held}"
@@ -2,8 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import subprocess
5
+ import threading
6
+ import time
7
+ from contextlib import contextmanager
5
8
  from pathlib import Path
6
- from typing import Dict, List, Optional, Sequence, Tuple
9
+ from typing import Dict, Iterator, List, Optional, Sequence, Tuple
7
10
 
8
11
  import pytest
9
12
 
@@ -136,11 +139,12 @@ class FetchTimesOut:
136
139
 
137
140
  def test_fetch_pull_push_and_clone_receive_network_timeouts(tmp_path: Path) -> None:
138
141
  root = tmp_path / "home"
139
- root.mkdir()
142
+ (root / ".git").mkdir(parents=True)
140
143
  _write(root / MARKER, "memory sync enabled\n")
141
144
  common = {
142
145
  ("rev-parse", "--is-inside-work-tree"): "true",
143
146
  ("rev-parse", "--show-toplevel"): str(root),
147
+ ("rev-parse", "--git-path", sync.LOCK_FILE): f".git/{sync.LOCK_FILE}",
144
148
  ("remote",): "origin",
145
149
  ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"): "origin/main",
146
150
  }
@@ -815,6 +819,10 @@ def test_pull_rebases_a_diverged_repository_and_reports_ahead(tmp_path: Path) ->
815
819
  outcome = sync.pull(home)
816
820
 
817
821
  assert outcome.status == "ahead" and outcome.ok
822
+ assert outcome.lines == (
823
+ "memory pull: rebased local commits onto upstream.",
824
+ "memory pull: 1 unpushed commit(s); use push to deliver.",
825
+ )
818
826
  assert (home / "org-memory" / "remote.md").exists()
819
827
 
820
828
 
@@ -829,7 +837,8 @@ def test_pull_with_unpushed_commits_is_not_an_error(tmp_path: Path) -> None:
829
837
  outcome = sync.pull(home)
830
838
 
831
839
  assert outcome.status == "ahead" and outcome.ok
832
- assert "1 unpushed commit(s)" in outcome.lines[-1]
840
+ # Nothing came down, so no "already synced" ahead of the unpushed count.
841
+ assert outcome.lines == ("memory pull: 1 unpushed commit(s); use push to deliver.",)
833
842
 
834
843
 
835
844
  def test_pull_when_already_synced(tmp_path: Path) -> None:
@@ -926,6 +935,29 @@ def test_init_is_idempotent(tmp_path: Path) -> None:
926
935
  assert {path: path.read_bytes() for path in home.rglob("*") if path.is_file() and ".git" not in path.parts} == before
927
936
 
928
937
 
938
+ @pytest.mark.parametrize("local_default", ["master", "main"])
939
+ def test_init_starts_a_new_repository_on_main_whatever_the_local_default(
940
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, local_default: str
941
+ ) -> None:
942
+ monkeypatch.setenv("GIT_CONFIG_VALUE_0", local_default)
943
+ home = _local_home(tmp_path)
944
+ assert _git("symbolic-ref", "--short", "HEAD", cwd=home) == "main"
945
+ assert _git("branch", "--format=%(refname:short)", cwd=home) == "main"
946
+
947
+
948
+ def test_init_keeps_the_branch_of_an_existing_repository(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
949
+ monkeypatch.setenv("GIT_CONFIG_VALUE_0", "master")
950
+ home = tmp_path / "home"
951
+ home.mkdir()
952
+ _git("init", "--quiet", cwd=home)
953
+
954
+ assert sync.init(home).ok
955
+ assert sync.init(home).ok
956
+
957
+ assert _git("branch", "--format=%(refname:short)", cwd=home) == "master"
958
+ assert _head_paths(home) == [".gitignore", MARKER]
959
+
960
+
929
961
  def test_init_adds_origin_when_another_remote_exists(tmp_path: Path) -> None:
930
962
  remote = tmp_path / "memory.git"
931
963
  other = tmp_path / "other.git"
@@ -1094,6 +1126,87 @@ def test_existing_rebase_is_never_published_or_aborted(tmp_path: Path, verb) ->
1094
1126
  assert (home / ".git/rebase-merge").exists()
1095
1127
 
1096
1128
 
1129
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1130
+ def test_a_rebase_begun_after_the_precheck_is_never_aborted(tmp_path: Path, verb) -> None:
1131
+ # The in-progress precheck runs before the fetch. A rebase somebody else starts
1132
+ # once the upstream has been read, just before ours, is theirs and must survive.
1133
+ remote, seed = _create_remote(tmp_path)
1134
+ home = tmp_path / "home"
1135
+ sync.clone(home, str(remote))
1136
+ _write(home / "org-memory/local.md", "local\n")
1137
+ _git("add", "org-memory/local.md", cwd=home)
1138
+ _git("commit", "-qm", "local", cwd=home)
1139
+ _commit_and_push(seed, "org-memory/remote.md", "remote\n")
1140
+
1141
+ def runner(args, *, cwd, timeout=None):
1142
+ result = run_git(args, cwd=cwd, timeout=timeout)
1143
+ if tuple(args[:2]) == ("rev-list", "--merges"):
1144
+ # Stops at the failing exec with a clean tree and its state directory in place.
1145
+ assert not run_git(["rebase", "--exec", "false", "HEAD~1"], cwd=cwd).ok
1146
+ assert (home / ".git/rebase-merge").exists()
1147
+ return result
1148
+
1149
+ outcome = verb(home, agent="test-agent", runner=runner)
1150
+ assert outcome.status == "operation_in_progress" and not outcome.ok
1151
+ assert (home / ".git/rebase-merge").exists()
1152
+ assert not _remote_has(remote, "org-memory/local.md")
1153
+
1154
+
1155
+ @contextmanager
1156
+ def _held_sync_lock(home: Path) -> Iterator[None]:
1157
+ """Hold the home's sync lock the way a concurrent agent-memory process would."""
1158
+ fcntl = pytest.importorskip("fcntl")
1159
+ with open(home / ".git" / sync.LOCK_FILE, "a") as handle:
1160
+ fcntl.flock(handle, fcntl.LOCK_EX)
1161
+ yield
1162
+
1163
+
1164
+ @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1165
+ def test_a_sync_held_elsewhere_past_the_wait_changes_nothing(
1166
+ tmp_path: Path, verb, monkeypatch: pytest.MonkeyPatch
1167
+ ) -> None:
1168
+ remote, seed = _create_remote(tmp_path)
1169
+ home = tmp_path / "home"
1170
+ sync.clone(home, str(remote))
1171
+ _commit_and_push(seed, "org-memory/remote.md", "remote\n")
1172
+ _write(home / "org-memory/local.md", "local\n")
1173
+ monkeypatch.setattr(sync, "LOCK_WAIT_SECONDS", 0.2)
1174
+ runner = FetchTimesOut()
1175
+
1176
+ with _held_sync_lock(home):
1177
+ outcome = verb(home, agent="test-agent", runner=runner)
1178
+
1179
+ assert outcome.status == "sync_in_progress" and not outcome.ok
1180
+ assert "another agent-memory sync" in outcome.lines[-1]
1181
+ assert not any(call[0] in {"fetch", "add", "commit", "rebase"} for call in runner.calls)
1182
+ assert not (home / "org-memory/remote.md").exists()
1183
+ assert _git("status", "--porcelain", cwd=home) == "?? org-memory/local.md"
1184
+
1185
+
1186
+ def test_a_second_sync_waits_for_the_first_to_finish(tmp_path: Path) -> None:
1187
+ pytest.importorskip("fcntl")
1188
+ remote, seed = _create_remote(tmp_path)
1189
+ home = tmp_path / "home"
1190
+ sync.clone(home, str(remote))
1191
+ _commit_and_push(seed, "org-memory/remote.md", "remote\n")
1192
+ held = threading.Event()
1193
+
1194
+ def first_sync() -> None:
1195
+ with _held_sync_lock(home):
1196
+ held.set()
1197
+ time.sleep(0.3)
1198
+
1199
+ holder = threading.Thread(target=first_sync)
1200
+ holder.start()
1201
+ assert held.wait(timeout=10), "the first sync never took the lock"
1202
+ started = time.monotonic()
1203
+ outcome = sync.pull(home)
1204
+ holder.join()
1205
+
1206
+ assert outcome.status == "synced" and outcome.ok
1207
+ assert time.monotonic() - started >= 0.2, "the second sync ran alongside the first"
1208
+
1209
+
1097
1210
  @pytest.mark.parametrize("verb", [sync.pull, sync.push])
1098
1211
  def test_dirty_fetch_timeout_keeps_commit_and_identity(tmp_path: Path, verb) -> None:
1099
1212
  remote, _ = _create_remote(tmp_path)
@@ -1181,3 +1294,53 @@ def test_local_merge_content_is_not_dropped_by_rebase(tmp_path: Path, verb) -> N
1181
1294
  assert "merge commits" in outcome.lines[-1]
1182
1295
  assert _git("rev-parse", "HEAD", cwd=home) == before
1183
1296
  assert (home / "org-memory/merge-only.md").read_text() == "merge resolution\n"
1297
+
1298
+
1299
+ # Real git shapes; all repository names, identities and contents are synthetic.
1300
+ def test_pull_holds_paths_the_predicate_names_and_updates_past_them(tmp_path: Path) -> None:
1301
+ remote, seed = _create_remote(tmp_path)
1302
+ home = tmp_path / "home"
1303
+ sync.clone(home, str(remote))
1304
+ _commit_and_push(seed, "org-memory/peer.md", "peer content\n")
1305
+ _write(home / "org-memory/held.md", "someone else's\n")
1306
+ _write(home / "org-memory/local.md", "local content\n")
1307
+ outcome = sync.pull(home, agent="test-agent", hold=lambda path: path == "org-memory/held.md")
1308
+ assert outcome.ok and outcome.status == "pushed", outcome.lines
1309
+ assert outcome.held == ("org-memory/held.md",)
1310
+ assert outcome.committed == ("org-memory/local.md",)
1311
+ assert _git("status", "--porcelain", cwd=home) == "?? org-memory/held.md"
1312
+ assert _remote_has(remote, "org-memory/local.md") and not _remote_has(remote, "org-memory/held.md")
1313
+ assert (home / "org-memory/peer.md").read_text() == "peer content\n"
1314
+ assert any("1 untracked path(s) left in place: org-memory/held.md" in line for line in outcome.lines)
1315
+
1316
+
1317
+ @pytest.mark.parametrize(
1318
+ ("held", "incoming"),
1319
+ [("org-memory/held.md", "org-memory/held.md"), ("org-memory/held/inner.md", "org-memory/held")],
1320
+ )
1321
+ def test_an_untracked_path_the_upstream_touches_still_blocks_the_update(tmp_path: Path, held: str, incoming: str) -> None:
1322
+ remote, seed = _create_remote(tmp_path)
1323
+ home = tmp_path / "home"
1324
+ sync.clone(home, str(remote))
1325
+ _commit_and_push(seed, incoming, "upstream\n")
1326
+ _write(home / held, "local\n")
1327
+ before = _git("rev-parse", "HEAD", cwd=home)
1328
+ outcome = sync.pull(home, agent="test-agent", hold=lambda path: path == held)
1329
+ assert outcome.status == "dirty" and not outcome.ok
1330
+ assert outcome.lines[-1].endswith(f"left untouched: {held}")
1331
+ assert _git("rev-parse", "HEAD", cwd=home) == before
1332
+ assert (home / held).read_text() == "local\n"
1333
+
1334
+
1335
+ def test_a_tracked_change_left_behind_still_blocks_the_update(tmp_path: Path) -> None:
1336
+ remote, seed = _create_remote(tmp_path)
1337
+ home = tmp_path / "home"
1338
+ sync.clone(home, str(remote))
1339
+ _commit_and_push(seed, "org-memory/recent.md", "base\n")
1340
+ assert sync.pull(home).ok
1341
+ _commit_and_push(seed, "org-memory/peer.md", "peer content\n")
1342
+ _write(home / "org-memory/recent.md", "edited by someone else\n")
1343
+ outcome = sync.pull(home, agent="test-agent", hold=lambda path: path == "org-memory/recent.md")
1344
+ assert outcome.status == "dirty" and not outcome.ok
1345
+ assert "org-memory/recent.md" in outcome.lines[-1]
1346
+ assert not (home / "org-memory/peer.md").exists()