agent-memory-cli 0.1.0__py3-none-any.whl
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.
- agent_memory/__init__.py +9 -0
- agent_memory/__main__.py +9 -0
- agent_memory/archive.py +359 -0
- agent_memory/cli.py +437 -0
- agent_memory/debrief.py +336 -0
- agent_memory/doctor.py +710 -0
- agent_memory/git_runner.py +68 -0
- agent_memory/home.py +246 -0
- agent_memory/layout.py +198 -0
- agent_memory/org.py +218 -0
- agent_memory/publication.py +433 -0
- agent_memory/setup/__init__.py +53 -0
- agent_memory/setup/claude.py +34 -0
- agent_memory/setup/codex.py +39 -0
- agent_memory/setup/common.py +1015 -0
- agent_memory/setup/legacy.py +67 -0
- agent_memory/startup.py +254 -0
- agent_memory/status.py +137 -0
- agent_memory/sync.py +649 -0
- agent_memory/templates/org-memory/decisions.md +5 -0
- agent_memory/templates/org-memory/recent.md +16 -0
- agent_memory/templates/org-memory/rules.md +5 -0
- agent_memory/templates/project-memory/decision_log.md +3 -0
- agent_memory/templates/project-memory/known_debt.md +8 -0
- agent_memory/templates/project-memory/open_threads.md +3 -0
- agent_memory/templates/project-memory/project_facts.md +4 -0
- agent_memory/templates/workflow/SKILL.md +42 -0
- agent_memory/workflow.py +287 -0
- agent_memory_cli-0.1.0.dist-info/METADATA +261 -0
- agent_memory_cli-0.1.0.dist-info/RECORD +33 -0
- agent_memory_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_memory_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
agent_memory/sync.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Sync a memory home through plain git.
|
|
4
|
+
|
|
5
|
+
The engine owns the sync marker, the managed block of the home's
|
|
6
|
+
``.gitignore``, the git state readout and the verbs ``init``, ``clone``,
|
|
7
|
+
``pull``, ``push`` and ``disable``. It never reads memory content, never
|
|
8
|
+
merges and never touches ``keys/``.
|
|
9
|
+
|
|
10
|
+
The publication boundary
|
|
11
|
+
------------------------
|
|
12
|
+
A memory commit holds the selected paths and nothing else. The selection is
|
|
13
|
+
``git status`` limited to the layout's sync allowlist, and every candidate
|
|
14
|
+
must pass the layout's path predicate, which denies a never-synced name such
|
|
15
|
+
as ``keys/`` at any depth whatever the ignore file says; if anything selected
|
|
16
|
+
fails it the publish is refused. Otherwise exactly that selection is staged
|
|
17
|
+
and committed as a partial commit, every name taken literally (a project
|
|
18
|
+
called ``a*`` is a name, not a pattern), so whatever else the index holds --
|
|
19
|
+
a runtime file somebody staged by hand -- stays staged, uncommitted and
|
|
20
|
+
reported. The home must be the root of its own git worktree; a home nested
|
|
21
|
+
inside another repository is refused before anything is written.
|
|
22
|
+
|
|
23
|
+
``.gitignore`` is never overwritten. The canonical allowlist is a managed
|
|
24
|
+
block that ``init`` puts at the head of the file when it is missing and
|
|
25
|
+
leaves alone when it is present, keeping every other line, and every write
|
|
26
|
+
comes back with a before/after receipt.
|
|
27
|
+
|
|
28
|
+
The network verbs (fetch, pull, push, clone) run under a 30 s timeout.
|
|
29
|
+
``pull`` fast-forwards only when the tree is clean, not ahead, not diverged
|
|
30
|
+
and an upstream exists. ``push`` refuses a repository that is behind or
|
|
31
|
+
diverged, and a push the remote rejects leaves the local commit in place and
|
|
32
|
+
says so: a local-only commit and remote delivery are distinct outcomes.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import datetime as dt
|
|
38
|
+
import os
|
|
39
|
+
import shutil
|
|
40
|
+
import socket
|
|
41
|
+
import tempfile
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
45
|
+
|
|
46
|
+
from . import layout
|
|
47
|
+
from .git_runner import GitResult, GitRunner, run_git
|
|
48
|
+
|
|
49
|
+
MARKER_FILE = layout.MARKER_FILE
|
|
50
|
+
GITIGNORE_FILE = layout.GITIGNORE_FILE
|
|
51
|
+
MARKER_TEXT = "agent-memory sync repository. Remove this file to disable syncing locally.\n"
|
|
52
|
+
NETWORK_TIMEOUT_SECONDS = 30
|
|
53
|
+
DEFAULT_REMOTE = "origin"
|
|
54
|
+
|
|
55
|
+
#: The agent name a commit is published under, when the caller does not pass one.
|
|
56
|
+
ENV_AGENT = "AGENT_MEMORY_AGENT"
|
|
57
|
+
_AGENT_FALLBACK_ENV = ("AGENT_NAME", "USER")
|
|
58
|
+
UNKNOWN_AGENT = "unknown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SyncError(Exception):
|
|
62
|
+
"""A precondition failed; nothing was changed."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class GitState:
|
|
67
|
+
"""Where the repository stands against its upstream, after a fetch."""
|
|
68
|
+
|
|
69
|
+
has_remote: bool
|
|
70
|
+
has_upstream: bool
|
|
71
|
+
upstream: str = ""
|
|
72
|
+
ahead: int = 0
|
|
73
|
+
behind: int = 0
|
|
74
|
+
dirty: bool = False
|
|
75
|
+
fetch_failed: bool = False
|
|
76
|
+
fetch_timed_out: bool = False
|
|
77
|
+
fetch_output: str = ""
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def diverged(self) -> bool:
|
|
81
|
+
return self.ahead > 0 and self.behind > 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class GitignoreReceipt:
|
|
86
|
+
"""What :func:`ensure_gitignore` found and what it left behind."""
|
|
87
|
+
|
|
88
|
+
path: Path
|
|
89
|
+
#: ``created``, ``unchanged`` or ``updated``.
|
|
90
|
+
action: str
|
|
91
|
+
before: Optional[str]
|
|
92
|
+
after: str
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def changed(self) -> bool:
|
|
96
|
+
return self.before != self.after
|
|
97
|
+
|
|
98
|
+
def lines(self) -> List[str]:
|
|
99
|
+
if not self.changed:
|
|
100
|
+
return [f"{GITIGNORE_FILE}: managed block present; left unchanged."]
|
|
101
|
+
if self.before is None:
|
|
102
|
+
return [f"{GITIGNORE_FILE}: created with the managed block."]
|
|
103
|
+
kept = self.after[len(layout.gitignore_text()) :]
|
|
104
|
+
out = [
|
|
105
|
+
f"{GITIGNORE_FILE}: managed block added at the top; "
|
|
106
|
+
f"{len(kept.splitlines())} existing line(s) kept after it.",
|
|
107
|
+
f"{GITIGNORE_FILE} before:",
|
|
108
|
+
*_indent(self.before.splitlines() or ["(empty)"]),
|
|
109
|
+
f"{GITIGNORE_FILE} after:",
|
|
110
|
+
*_indent(self.after.splitlines()),
|
|
111
|
+
]
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Outcome:
|
|
117
|
+
"""The result of a verb: a distinct status, whether it counts as success, and what to say."""
|
|
118
|
+
|
|
119
|
+
status: str
|
|
120
|
+
ok: bool
|
|
121
|
+
lines: Tuple[str, ...] = ()
|
|
122
|
+
#: Paths the verb committed, home-relative.
|
|
123
|
+
committed: Tuple[str, ...] = ()
|
|
124
|
+
#: Paths that were staged outside the selection and were left exactly as found.
|
|
125
|
+
preserved: Tuple[str, ...] = ()
|
|
126
|
+
receipt: Optional[GitignoreReceipt] = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- marker and ignore file -------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def marker_path(home: Path) -> Path:
|
|
133
|
+
return home / MARKER_FILE
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_configured(home: Path) -> bool:
|
|
137
|
+
return marker_path(home).is_file()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_marker(home: Path) -> bool:
|
|
141
|
+
"""Create the marker if it is missing; an existing marker keeps its bytes."""
|
|
142
|
+
path = marker_path(home)
|
|
143
|
+
if path.is_file():
|
|
144
|
+
return False
|
|
145
|
+
path.write_text(MARKER_TEXT, encoding="utf-8")
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ensure_gitignore(home: Path) -> GitignoreReceipt:
|
|
150
|
+
"""Put the managed block at the head of ``.gitignore`` unless it is already there.
|
|
151
|
+
|
|
152
|
+
The block is the canonical allowlist from :func:`layout.gitignore_text`.
|
|
153
|
+
A missing file is created with the block alone, so a fresh home's file is
|
|
154
|
+
byte-identical to the canonical text. A file that already contains the
|
|
155
|
+
block, contiguous and on line boundaries, is left untouched wherever the
|
|
156
|
+
block sits. Otherwise the block goes first and every line of the existing
|
|
157
|
+
file that is not itself a block line follows it verbatim: custom rules
|
|
158
|
+
survive, and a file carrying an older version of the block is brought up
|
|
159
|
+
to date without duplicating its lines.
|
|
160
|
+
"""
|
|
161
|
+
path = home / GITIGNORE_FILE
|
|
162
|
+
block = layout.gitignore_text()
|
|
163
|
+
try:
|
|
164
|
+
before: Optional[str] = path.read_text(encoding="utf-8")
|
|
165
|
+
except FileNotFoundError:
|
|
166
|
+
before = None
|
|
167
|
+
if before is None:
|
|
168
|
+
after, action = block, "created"
|
|
169
|
+
elif _contains_block(before, block):
|
|
170
|
+
after, action = before, "unchanged"
|
|
171
|
+
else:
|
|
172
|
+
managed = set(block.splitlines())
|
|
173
|
+
kept = [line for line in before.replace("\r\n", "\n").splitlines() if line not in managed]
|
|
174
|
+
after = block + ("\n".join(kept) + "\n" if kept else "")
|
|
175
|
+
action = "updated"
|
|
176
|
+
if after != before:
|
|
177
|
+
path.write_text(after, encoding="utf-8")
|
|
178
|
+
return GitignoreReceipt(path, action, before, after)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _contains_block(text: str, block: str) -> bool:
|
|
182
|
+
normalized = text.replace("\r\n", "\n")
|
|
183
|
+
return normalized.startswith(block) or f"\n{block}" in normalized
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def gitignore_has_managed_block(text: str) -> bool:
|
|
187
|
+
"""Whether ``text`` carries the managed allowlist block, contiguous and on line boundaries."""
|
|
188
|
+
return _contains_block(text, layout.gitignore_text())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --- git readout ------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def is_git_repo(home: Path, runner: Optional[GitRunner] = None) -> bool:
|
|
195
|
+
return _git(home, ["rev-parse", "--is-inside-work-tree"], runner).ok
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def worktree_root(home: Path, runner: Optional[GitRunner] = None) -> Optional[Path]:
|
|
199
|
+
"""The root of the worktree ``home`` sits in, or ``None`` outside any repository."""
|
|
200
|
+
result = _git(home, ["rev-parse", "--show-toplevel"], runner)
|
|
201
|
+
if not result.ok or not result.stdout.strip():
|
|
202
|
+
return None
|
|
203
|
+
return Path(result.stdout.strip())
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def git_state(home: Path, *, runner: Optional[GitRunner] = None, fetch: bool = True) -> GitState:
|
|
207
|
+
dirty = bool(_status_porcelain(home, runner))
|
|
208
|
+
remote = _has_remote(home, runner)
|
|
209
|
+
fetch_failed = fetch_timed_out = False
|
|
210
|
+
fetch_output = ""
|
|
211
|
+
if fetch and remote:
|
|
212
|
+
fetched = _git(home, ["fetch", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
|
|
213
|
+
fetch_failed = not fetched.ok
|
|
214
|
+
fetch_timed_out = fetched.timed_out
|
|
215
|
+
fetch_output = fetched.output
|
|
216
|
+
upstream = _upstream(home, runner)
|
|
217
|
+
ahead = behind = 0
|
|
218
|
+
if upstream:
|
|
219
|
+
counted = _git(home, ["rev-list", "--left-right", "--count", f"HEAD...{upstream}"], runner)
|
|
220
|
+
parts = counted.stdout.split()
|
|
221
|
+
if counted.ok and len(parts) >= 2:
|
|
222
|
+
ahead, behind = int(parts[0]), int(parts[1])
|
|
223
|
+
else:
|
|
224
|
+
fetch_failed, fetch_output = True, counted.output
|
|
225
|
+
return GitState(
|
|
226
|
+
has_remote=remote,
|
|
227
|
+
has_upstream=bool(upstream),
|
|
228
|
+
upstream=upstream,
|
|
229
|
+
ahead=ahead,
|
|
230
|
+
behind=behind,
|
|
231
|
+
dirty=dirty,
|
|
232
|
+
fetch_failed=fetch_failed,
|
|
233
|
+
fetch_timed_out=fetch_timed_out,
|
|
234
|
+
fetch_output=fetch_output,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --- the verbs --------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def init(
|
|
242
|
+
home: Path,
|
|
243
|
+
*,
|
|
244
|
+
remote: Optional[str] = None,
|
|
245
|
+
agent: Optional[str] = None,
|
|
246
|
+
runner: Optional[GitRunner] = None,
|
|
247
|
+
env: Optional[Mapping[str, str]] = None,
|
|
248
|
+
) -> Outcome:
|
|
249
|
+
"""Make ``home`` a memory repository: git, the managed ignore block, the marker, one commit.
|
|
250
|
+
|
|
251
|
+
Rerunning on an initialized home changes nothing it does not have to:
|
|
252
|
+
the ignore block is only added when missing, the marker keeps its bytes,
|
|
253
|
+
and the commit covers only what the allowlist selects.
|
|
254
|
+
"""
|
|
255
|
+
home = _home(home)
|
|
256
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
if is_git_repo(home, runner):
|
|
258
|
+
_require_root(home, runner)
|
|
259
|
+
else:
|
|
260
|
+
result = _git(home, ["init", "--quiet"], runner)
|
|
261
|
+
if not result.ok:
|
|
262
|
+
raise SyncError(f"git init failed: {result.output}")
|
|
263
|
+
receipt = ensure_gitignore(home)
|
|
264
|
+
lines = receipt.lines()
|
|
265
|
+
if write_marker(home):
|
|
266
|
+
lines.append(f"{MARKER_FILE}: created.")
|
|
267
|
+
if remote:
|
|
268
|
+
verb = "set-url" if _git(home, ["remote", "get-url", DEFAULT_REMOTE], runner).ok else "add"
|
|
269
|
+
result = _git(home, ["remote", verb, DEFAULT_REMOTE, remote], runner)
|
|
270
|
+
if not result.ok:
|
|
271
|
+
raise SyncError(f"git remote {verb} failed: {result.output}")
|
|
272
|
+
lines.append(f"remote {DEFAULT_REMOTE}: {remote}")
|
|
273
|
+
|
|
274
|
+
published = _publish(home, agent=agent, runner=runner, env=env)
|
|
275
|
+
lines.extend(published.lines)
|
|
276
|
+
if not published.ok:
|
|
277
|
+
return _with(published, lines=lines, receipt=receipt)
|
|
278
|
+
if not remote:
|
|
279
|
+
return Outcome("local_only", True, tuple(lines), published.committed, published.preserved, receipt)
|
|
280
|
+
return _deliver(home, published, git_state(home, runner=runner, fetch=False), lines, runner, receipt)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def clone(home: Path, url: str, *, force: bool = False, runner: Optional[GitRunner] = None) -> Outcome:
|
|
284
|
+
"""Clone a memory repository into ``home``; a non-empty ``home`` is refused unless ``force``.
|
|
285
|
+
|
|
286
|
+
With ``force`` the existing directory is moved aside to a timestamped
|
|
287
|
+
sibling first and moved back if the clone fails.
|
|
288
|
+
"""
|
|
289
|
+
home = _home(home)
|
|
290
|
+
if not _is_non_empty(home):
|
|
291
|
+
home.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
result = _git(home.parent, ["clone", url, str(home)], runner, timeout=NETWORK_TIMEOUT_SECONDS)
|
|
293
|
+
if not result.ok:
|
|
294
|
+
raise SyncError(f"git clone failed: {result.output}")
|
|
295
|
+
return Outcome("cloned", True, (f"Cloned the memory repository into {home}.",))
|
|
296
|
+
if not force:
|
|
297
|
+
raise SyncError(f"refusing to clone into a non-empty home: {home}; pass --force to move it aside first")
|
|
298
|
+
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
299
|
+
backup = home.with_name(f"{home.name}.backup-{stamp}")
|
|
300
|
+
shutil.move(str(home), str(backup))
|
|
301
|
+
try:
|
|
302
|
+
result = _git(home.parent, ["clone", url, str(home)], runner, timeout=NETWORK_TIMEOUT_SECONDS)
|
|
303
|
+
except Exception:
|
|
304
|
+
if not home.exists():
|
|
305
|
+
shutil.move(str(backup), str(home))
|
|
306
|
+
raise
|
|
307
|
+
if not result.ok:
|
|
308
|
+
if home.exists():
|
|
309
|
+
shutil.rmtree(home)
|
|
310
|
+
shutil.move(str(backup), str(home))
|
|
311
|
+
raise SyncError(f"git clone failed: {result.output}")
|
|
312
|
+
return Outcome(
|
|
313
|
+
"cloned",
|
|
314
|
+
True,
|
|
315
|
+
(f"Moved the existing home aside to {backup}.", f"Cloned the memory repository into {home}."),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def pull(home: Path, *, runner: Optional[GitRunner] = None) -> Outcome:
|
|
320
|
+
"""Fast-forward ``home`` from its upstream when that is the only thing that could happen.
|
|
321
|
+
|
|
322
|
+
Silent on a home that is not configured for sync. Every state that rules
|
|
323
|
+
a fast-forward out (a failed fetch, a dirty tree, divergence) is its own
|
|
324
|
+
status and not a success; being ahead or having no upstream is reported
|
|
325
|
+
and is not an error.
|
|
326
|
+
"""
|
|
327
|
+
home = _home(home)
|
|
328
|
+
if not is_configured(home):
|
|
329
|
+
return Outcome("not_configured", True)
|
|
330
|
+
_require_repo(home, runner)
|
|
331
|
+
state = git_state(home, runner=runner, fetch=True)
|
|
332
|
+
if not state.has_remote:
|
|
333
|
+
return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",))
|
|
334
|
+
if state.fetch_failed:
|
|
335
|
+
return _fetch_failure(state, "memory pull")
|
|
336
|
+
if state.dirty:
|
|
337
|
+
return Outcome("dirty", False, ("memory pull: uncommitted changes present; not pulling.",))
|
|
338
|
+
if state.diverged:
|
|
339
|
+
return Outcome("diverged", False, ("memory pull: diverged from upstream; resolve manually.",))
|
|
340
|
+
if not state.has_upstream:
|
|
341
|
+
return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",))
|
|
342
|
+
if state.ahead:
|
|
343
|
+
return Outcome("ahead", True, (f"memory pull: {state.ahead} unpushed commit(s); nothing to pull.",))
|
|
344
|
+
if not state.behind:
|
|
345
|
+
return Outcome("up_to_date", True, ("memory pull: already synced.",))
|
|
346
|
+
result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
|
|
347
|
+
if result.timed_out:
|
|
348
|
+
return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",))
|
|
349
|
+
if not result.ok:
|
|
350
|
+
return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",))
|
|
351
|
+
return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def push(
|
|
355
|
+
home: Path,
|
|
356
|
+
*,
|
|
357
|
+
agent: Optional[str] = None,
|
|
358
|
+
runner: Optional[GitRunner] = None,
|
|
359
|
+
env: Optional[Mapping[str, str]] = None,
|
|
360
|
+
) -> Outcome:
|
|
361
|
+
"""Commit the allowlisted memory changes and deliver them when a remote exists.
|
|
362
|
+
|
|
363
|
+
Silent on a home that is not configured for sync. A home that is behind
|
|
364
|
+
or diverged is refused before anything is committed. A commit that could
|
|
365
|
+
not be delivered -- no remote, an unreachable one, a rejected push -- is
|
|
366
|
+
reported as such, distinctly from delivery.
|
|
367
|
+
"""
|
|
368
|
+
home = _home(home)
|
|
369
|
+
if not is_configured(home):
|
|
370
|
+
return Outcome("not_configured", True)
|
|
371
|
+
_require_repo(home, runner)
|
|
372
|
+
state = git_state(home, runner=runner, fetch=True)
|
|
373
|
+
if state.has_remote and not state.fetch_failed:
|
|
374
|
+
if state.diverged:
|
|
375
|
+
return Outcome("diverged", False, ("memory push: diverged from upstream; resolve manually before pushing.",))
|
|
376
|
+
if state.behind:
|
|
377
|
+
return Outcome(
|
|
378
|
+
"behind", False, (f"memory push: behind upstream by {state.behind} commit(s); pull before pushing.",)
|
|
379
|
+
)
|
|
380
|
+
published = _publish(home, agent=agent, runner=runner, env=env)
|
|
381
|
+
if not published.ok:
|
|
382
|
+
return published
|
|
383
|
+
lines = list(published.lines)
|
|
384
|
+
if not state.has_remote:
|
|
385
|
+
lines.append("memory push: no remote configured; the commit remains local.")
|
|
386
|
+
return Outcome("local_only", True, tuple(lines), published.committed, published.preserved)
|
|
387
|
+
return _deliver(home, published, state, lines, runner)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def disable(home: Path) -> Outcome:
|
|
391
|
+
"""Remove the marker; the repository and its history stay in place."""
|
|
392
|
+
home = _home(home)
|
|
393
|
+
path = marker_path(home)
|
|
394
|
+
if path.exists():
|
|
395
|
+
path.unlink()
|
|
396
|
+
return Outcome("disabled", True, (f"Removed {path}; syncing is disabled for this home.",))
|
|
397
|
+
return Outcome("already_disabled", True, ("Syncing is already disabled for this home.",))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# --- the publication boundary -----------------------------------------------
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def select_paths(home: Path, runner: Optional[GitRunner] = None) -> Tuple[List[str], List[str]]:
|
|
404
|
+
"""Split what ``git status`` reports inside the allowlisted tiers into (selected, outside).
|
|
405
|
+
|
|
406
|
+
The query is limited to the layout's tier patterns plus the ignore file
|
|
407
|
+
and the marker, so nothing elsewhere in the tree is ever a candidate; and
|
|
408
|
+
every candidate is checked against :func:`layout.is_allowed_memory_path`,
|
|
409
|
+
so a widened ignore file cannot smuggle a tier's unsynced subdirectory in.
|
|
410
|
+
"""
|
|
411
|
+
pathspecs = [GITIGNORE_FILE, MARKER_FILE, *(f":(glob){tier.pattern}/**" for tier in layout.TIERS)]
|
|
412
|
+
result = _git(home, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", *pathspecs], runner)
|
|
413
|
+
if not result.ok:
|
|
414
|
+
raise SyncError(f"git status failed: {result.output}")
|
|
415
|
+
paths = _parse_status_z(result.stdout)
|
|
416
|
+
selected = sorted(path for path in paths if layout.is_allowed_memory_path(path))
|
|
417
|
+
outside = sorted(path for path in paths if not layout.is_allowed_memory_path(path))
|
|
418
|
+
return selected, outside
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def staged_paths(home: Path, runner: Optional[GitRunner] = None) -> List[str]:
|
|
422
|
+
"""Every path the index differs in from HEAD (or from the empty tree before the first commit)."""
|
|
423
|
+
result = _git(home, ["diff", "--cached", "--name-only", "-z"], runner)
|
|
424
|
+
if not result.ok:
|
|
425
|
+
raise SyncError(f"git diff --cached failed: {result.output}")
|
|
426
|
+
return [path for path in result.stdout.split("\0") if path]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def committed_paths(home: Path, runner: Optional[GitRunner] = None) -> List[str]:
|
|
430
|
+
"""The paths HEAD's commit touched."""
|
|
431
|
+
result = _git(home, ["show", "--format=", "--name-only", "-z", "HEAD"], runner)
|
|
432
|
+
if not result.ok:
|
|
433
|
+
raise SyncError(f"git show failed: {result.output}")
|
|
434
|
+
return [path for path in result.stdout.split("\0") if path]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def resolve_agent(explicit: Optional[str] = None, env: Optional[Mapping[str, str]] = None) -> str:
|
|
438
|
+
"""The name a commit is published under: the caller's, else the environment's, else ``unknown``."""
|
|
439
|
+
if explicit:
|
|
440
|
+
return explicit
|
|
441
|
+
source: Mapping[str, str] = os.environ if env is None else env
|
|
442
|
+
for name in (ENV_AGENT, *_AGENT_FALLBACK_ENV):
|
|
443
|
+
value = source.get(name)
|
|
444
|
+
if value:
|
|
445
|
+
return value
|
|
446
|
+
return UNKNOWN_AGENT
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def commit_message(file_count: int, agent: str, *, now: Optional[dt.datetime] = None) -> str:
|
|
450
|
+
host = socket.gethostname().split(".")[0] or "host"
|
|
451
|
+
today = (now or dt.datetime.now(dt.timezone.utc)).strftime("%Y-%m-%d")
|
|
452
|
+
return f"memory: {agent}@{host} {today} ({file_count} files)"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _publish(
|
|
456
|
+
home: Path,
|
|
457
|
+
*,
|
|
458
|
+
agent: Optional[str],
|
|
459
|
+
runner: Optional[GitRunner],
|
|
460
|
+
env: Optional[Mapping[str, str]],
|
|
461
|
+
) -> Outcome:
|
|
462
|
+
selected, outside = select_paths(home, runner)
|
|
463
|
+
if outside:
|
|
464
|
+
return Outcome(
|
|
465
|
+
"outside_allowlist",
|
|
466
|
+
False,
|
|
467
|
+
(
|
|
468
|
+
f"memory publish: refusing to commit; {len(outside)} path(s) inside a memory tier fall outside "
|
|
469
|
+
f"the sync allowlist (a never-synced name such as keys/, or a widened {GITIGNORE_FILE}): "
|
|
470
|
+
f"{', '.join(outside)}",
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
preserved = tuple(sorted(set(staged_paths(home, runner)) - set(selected)))
|
|
474
|
+
lines: List[str] = []
|
|
475
|
+
if preserved:
|
|
476
|
+
lines.append(
|
|
477
|
+
f"memory publish: {len(preserved)} staged path(s) outside the allowlist left staged and "
|
|
478
|
+
f"uncommitted: {', '.join(preserved)}"
|
|
479
|
+
)
|
|
480
|
+
if not selected:
|
|
481
|
+
lines.append("memory publish: no memory changes to commit.")
|
|
482
|
+
return Outcome("nothing_to_commit", True, tuple(lines), (), preserved)
|
|
483
|
+
|
|
484
|
+
message = commit_message(len(selected), resolve_agent(agent, env))
|
|
485
|
+
with _pathspec_file(selected) as pathspec:
|
|
486
|
+
added = _git(home, ["--literal-pathspecs", "add", *_from_file(pathspec)], runner)
|
|
487
|
+
if not added.ok:
|
|
488
|
+
return Outcome("commit_failed", False, (*lines, f"memory publish: git add failed: {added.output}"))
|
|
489
|
+
committed = _git(home, ["--literal-pathspecs", "commit", "--quiet", "-m", message, *_from_file(pathspec)], runner)
|
|
490
|
+
if not committed.ok:
|
|
491
|
+
return Outcome("commit_failed", False, (*lines, f"memory publish: git commit failed: {committed.output}"))
|
|
492
|
+
published = committed_paths(home, runner)
|
|
493
|
+
stray = sorted(path for path in published if not layout.is_allowed_memory_path(path))
|
|
494
|
+
if stray:
|
|
495
|
+
raise SyncError(f"memory publish: HEAD commits paths outside the allowlist: {', '.join(stray)}")
|
|
496
|
+
lines.append(f"memory publish: committed {len(published)} file(s).")
|
|
497
|
+
return Outcome("committed", True, tuple(lines), tuple(published), preserved)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _deliver(
|
|
501
|
+
home: Path,
|
|
502
|
+
published: Outcome,
|
|
503
|
+
state: GitState,
|
|
504
|
+
lines: List[str],
|
|
505
|
+
runner: Optional[GitRunner],
|
|
506
|
+
receipt: Optional[GitignoreReceipt] = None,
|
|
507
|
+
) -> Outcome:
|
|
508
|
+
if state.fetch_failed:
|
|
509
|
+
failure = _fetch_failure(state, "memory push")
|
|
510
|
+
lines.append(f"{failure.lines[0]} The commit remains local.")
|
|
511
|
+
return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt)
|
|
512
|
+
if not published.committed and state.has_upstream and not state.ahead:
|
|
513
|
+
lines.append("memory push: remote already up to date.")
|
|
514
|
+
return Outcome("up_to_date", True, tuple(lines), (), published.preserved, receipt)
|
|
515
|
+
result = _push_remote(home, state, runner)
|
|
516
|
+
if not result.ok:
|
|
517
|
+
what = "timed out" if result.timed_out else "was rejected"
|
|
518
|
+
lines.append(f"memory push: the push {what}; the commit remains local. {result.output}".rstrip())
|
|
519
|
+
status = "push_timed_out" if result.timed_out else "push_failed"
|
|
520
|
+
return Outcome(status, False, tuple(lines), published.committed, published.preserved, receipt)
|
|
521
|
+
lines.append("memory push: delivered to the remote.")
|
|
522
|
+
return Outcome("pushed", True, tuple(lines), published.committed, published.preserved, receipt)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _push_remote(home: Path, state: GitState, runner: Optional[GitRunner]) -> GitResult:
|
|
526
|
+
if state.has_upstream:
|
|
527
|
+
return _git(home, ["push", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
|
|
528
|
+
remote = _default_remote(home, runner)
|
|
529
|
+
branch = _current_branch(home, runner)
|
|
530
|
+
return _git(home, ["push", "--quiet", "-u", remote, branch], runner, timeout=NETWORK_TIMEOUT_SECONDS)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _fetch_failure(state: GitState, verb: str) -> Outcome:
|
|
534
|
+
if state.fetch_timed_out:
|
|
535
|
+
return Outcome("fetch_timed_out", False, (f"{verb}: the fetch timed out; {state.fetch_output}".rstrip(),))
|
|
536
|
+
return Outcome("fetch_failed", False, (f"{verb}: the fetch failed; {state.fetch_output}".rstrip(),))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# --- helpers ----------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _git(
|
|
543
|
+
cwd: Path, args: Sequence[str], runner: Optional[GitRunner], *, timeout: Optional[float] = None
|
|
544
|
+
) -> GitResult:
|
|
545
|
+
return (runner or run_git)(args, cwd=cwd, timeout=timeout)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _home(value: Path) -> Path:
|
|
549
|
+
return Path(value).expanduser().absolute()
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _require_repo(home: Path, runner: Optional[GitRunner]) -> None:
|
|
553
|
+
if not is_git_repo(home, runner):
|
|
554
|
+
raise SyncError(f"{MARKER_FILE} is present, but {home} is not a git repository")
|
|
555
|
+
_require_root(home, runner)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _require_root(home: Path, runner: Optional[GitRunner]) -> None:
|
|
559
|
+
root = worktree_root(home, runner)
|
|
560
|
+
if root is None:
|
|
561
|
+
raise SyncError(f"{home} is not inside a git worktree")
|
|
562
|
+
if root.resolve() != home.resolve():
|
|
563
|
+
raise SyncError(
|
|
564
|
+
f"root mismatch: {home} is inside the git worktree {root}; "
|
|
565
|
+
"a memory home must be the root of its own repository"
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _status_porcelain(home: Path, runner: Optional[GitRunner]) -> str:
|
|
570
|
+
result = _git(home, ["status", "--porcelain"], runner)
|
|
571
|
+
if not result.ok:
|
|
572
|
+
raise SyncError(f"git status failed: {result.output}")
|
|
573
|
+
return result.stdout.strip()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _has_remote(home: Path, runner: Optional[GitRunner]) -> bool:
|
|
577
|
+
result = _git(home, ["remote"], runner)
|
|
578
|
+
return result.ok and bool(result.stdout.strip())
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _default_remote(home: Path, runner: Optional[GitRunner]) -> str:
|
|
582
|
+
result = _git(home, ["remote"], runner)
|
|
583
|
+
remotes = [line.strip() for line in result.stdout.splitlines() if line.strip()] if result.ok else []
|
|
584
|
+
return remotes[0] if remotes else DEFAULT_REMOTE
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _upstream(home: Path, runner: Optional[GitRunner]) -> str:
|
|
588
|
+
result = _git(home, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], runner)
|
|
589
|
+
return result.stdout.strip() if result.ok else ""
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _current_branch(home: Path, runner: Optional[GitRunner]) -> str:
|
|
593
|
+
result = _git(home, ["branch", "--show-current"], runner)
|
|
594
|
+
return result.stdout.strip() if result.ok and result.stdout.strip() else "HEAD"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _is_non_empty(path: Path) -> bool:
|
|
598
|
+
return path.exists() and any(path.iterdir())
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _parse_status_z(data: str) -> List[str]:
|
|
602
|
+
"""Paths from ``git status --porcelain=v1 -z``; a rename or copy contributes both of its paths."""
|
|
603
|
+
paths: List[str] = []
|
|
604
|
+
fields = data.split("\0")
|
|
605
|
+
index = 0
|
|
606
|
+
while index < len(fields):
|
|
607
|
+
entry = fields[index]
|
|
608
|
+
index += 1
|
|
609
|
+
if len(entry) < 4:
|
|
610
|
+
continue
|
|
611
|
+
paths.append(entry[3:])
|
|
612
|
+
if entry[0] in "RC" and index < len(fields) and fields[index]:
|
|
613
|
+
paths.append(fields[index])
|
|
614
|
+
index += 1
|
|
615
|
+
return paths
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _from_file(pathspec: Path) -> List[str]:
|
|
619
|
+
return [f"--pathspec-from-file={pathspec}", "--pathspec-file-nul"]
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
class _pathspec_file:
|
|
623
|
+
"""A NUL-separated pathspec file for ``--pathspec-from-file``, removed on exit."""
|
|
624
|
+
|
|
625
|
+
def __init__(self, paths: Iterable[str]) -> None:
|
|
626
|
+
self._paths = list(paths)
|
|
627
|
+
self._path: Optional[Path] = None
|
|
628
|
+
|
|
629
|
+
def __enter__(self) -> Path:
|
|
630
|
+
handle = tempfile.NamedTemporaryFile("wb", prefix="agent-memory-pathspec-", delete=False)
|
|
631
|
+
with handle:
|
|
632
|
+
handle.write("\0".join(self._paths).encode("utf-8", "surrogateescape"))
|
|
633
|
+
self._path = Path(handle.name)
|
|
634
|
+
return self._path
|
|
635
|
+
|
|
636
|
+
def __exit__(self, *_: object) -> None:
|
|
637
|
+
if self._path is not None:
|
|
638
|
+
try:
|
|
639
|
+
self._path.unlink()
|
|
640
|
+
except OSError:
|
|
641
|
+
pass
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _with(outcome: Outcome, *, lines: Sequence[str], receipt: Optional[GitignoreReceipt]) -> Outcome:
|
|
645
|
+
return Outcome(outcome.status, outcome.ok, tuple(lines), outcome.committed, outcome.preserved, receipt)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _indent(lines: Iterable[str]) -> List[str]:
|
|
649
|
+
return [f" {line}" for line in lines]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Org Decisions
|
|
2
|
+
|
|
3
|
+
<!-- Org-wide technical choices, conventions, and architectural calls. -->
|
|
4
|
+
<!-- Curated by coordinator from events/ — patterns repeating 3+ times get promoted here. -->
|
|
5
|
+
<!-- This is an illustrative default topical file. Adopters may rename, remove, or add others. -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Org Memory — Rolling Summary
|
|
2
|
+
|
|
3
|
+
<!-- Always-loaded context for agents. Keep under ~150 lines. -->
|
|
4
|
+
<!-- Updated by coordinator during sync/curation. -->
|
|
5
|
+
|
|
6
|
+
## Current State
|
|
7
|
+
|
|
8
|
+
<!-- High-level summary of what's happening across the org. -->
|
|
9
|
+
|
|
10
|
+
## Active Decisions
|
|
11
|
+
|
|
12
|
+
<!-- Recent decisions that agents should be aware of. -->
|
|
13
|
+
|
|
14
|
+
## Standing Rules
|
|
15
|
+
|
|
16
|
+
<!-- Conventions and rules currently in effect. -->
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Org Rules
|
|
2
|
+
|
|
3
|
+
<!-- Standing conventions: naming, API patterns, timezone, style, etc. -->
|
|
4
|
+
<!-- Curated by coordinator from events/ — patterns repeating 3+ times get promoted here. -->
|
|
5
|
+
<!-- This is an illustrative default topical file. Adopters may rename, remove, or add others. -->
|