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/doctor.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Setup and health checks for a memory home: ``agent-memory doctor``.
|
|
4
|
+
|
|
5
|
+
Two categories, ported row for row from the 0.4.5 memory doctor (the golden
|
|
6
|
+
under ``tests/golden/``):
|
|
7
|
+
|
|
8
|
+
* **Org Memory** validates the debrief store's layout: the directory exists,
|
|
9
|
+
every record sits at ``<project>/<YYYY>/<MM>/<YYYYMMDD>-<agent>-<session>.md``,
|
|
10
|
+
no writer staging artifact lingers, and nothing under the store is a symlink
|
|
11
|
+
or otherwise irregular. It never opens a record, and a traversal it cannot
|
|
12
|
+
complete is its own error row, never a clean result.
|
|
13
|
+
* **Memory Sync** reads the sync marker, the root ``.gitignore``, and what git
|
|
14
|
+
reports about the home: tracked paths against the allowlist, untracked
|
|
15
|
+
memory-shaped files, the working tree, the upstream, the remote, the last
|
|
16
|
+
commit's age, per-instance ``agents/`` state, and the project ``.gitignore``
|
|
17
|
+
overlays. A git command that fails produces a warning row for its check,
|
|
18
|
+
never a pass.
|
|
19
|
+
|
|
20
|
+
The doctor reads no memory content and repairs nothing: a row that is not ok
|
|
21
|
+
carries a hint for the human, and the home is byte-identical after a run. Every
|
|
22
|
+
filesystem probe distinguishes a path that is absent from one it was denied,
|
|
23
|
+
so nothing unreadable reads as absent, and nothing absent reads as fine. The
|
|
24
|
+
result frame here is local to this module.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import datetime as dt
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
import stat
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from enum import Enum
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
38
|
+
|
|
39
|
+
from . import layout, sync
|
|
40
|
+
from .git_runner import GitResult, GitRunner, run_git
|
|
41
|
+
from .sync import GitState
|
|
42
|
+
|
|
43
|
+
#: A last commit older than this many days is reported stale.
|
|
44
|
+
STALE_MEMORY_DAYS = 7
|
|
45
|
+
|
|
46
|
+
#: The content checker the doctor points at when it is installed; content is its job.
|
|
47
|
+
MEMORY_LINT = "memory-lint"
|
|
48
|
+
|
|
49
|
+
# The agent segment is the canonical agent grammar; the session segment is
|
|
50
|
+
# hyphen-free, so the split on the last hyphen is deterministic.
|
|
51
|
+
_AGENT = r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}"
|
|
52
|
+
DEBRIEF_FILENAME_RE = re.compile(rf"^(?P<date>\d{{8}})-(?P<agent>{_AGENT})-(?P<session>[a-z0-9]{{1,32}})\.md$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- the result frame -------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Severity(Enum):
|
|
59
|
+
ok = "ok"
|
|
60
|
+
warn = "warn"
|
|
61
|
+
error = "error"
|
|
62
|
+
skip = "skip"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
SYMBOL = {
|
|
66
|
+
Severity.ok: "[+]",
|
|
67
|
+
Severity.warn: "[!]",
|
|
68
|
+
Severity.error: "[x]",
|
|
69
|
+
Severity.skip: "[-]",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Result:
|
|
75
|
+
"""One row: a stable name, a severity, the message, and a hint when it is not ok."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
severity: Severity
|
|
79
|
+
message: str
|
|
80
|
+
fix_hint: str = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Category:
|
|
85
|
+
"""One block of rows under a heading."""
|
|
86
|
+
|
|
87
|
+
name: str
|
|
88
|
+
results: List[Result] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def worst_severity(self) -> Severity:
|
|
92
|
+
for severity in (Severity.error, Severity.warn, Severity.skip, Severity.ok):
|
|
93
|
+
if any(result.severity is severity for result in self.results):
|
|
94
|
+
return severity
|
|
95
|
+
return Severity.ok
|
|
96
|
+
|
|
97
|
+
def add(self, name: str, severity: Severity, message: str, fix_hint: str = "") -> None:
|
|
98
|
+
self.results.append(Result(name, severity, message, fix_hint))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def has_errors(categories: Sequence[Category]) -> bool:
|
|
102
|
+
return any(result.severity is Severity.error for category in categories for result in category.results)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- running ----------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_doctor(
|
|
109
|
+
home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None
|
|
110
|
+
) -> List[Category]:
|
|
111
|
+
"""Both memory categories for ``home``, in report order."""
|
|
112
|
+
return [check_org_memory(home), check_memory_sync(home, runner=runner, now=now)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def find_memory_lint(which: Callable[[str], Optional[str]] = shutil.which) -> Optional[str]:
|
|
116
|
+
"""Where ``memory-lint`` is on PATH, or ``None``."""
|
|
117
|
+
return which(MEMORY_LINT)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def report(categories: Sequence[Category], *, memory_lint: Optional[str] = None) -> str:
|
|
121
|
+
"""The text report: one block per category, a verdict line, and the pointer to memory-lint when it is installed."""
|
|
122
|
+
lines: List[str] = []
|
|
123
|
+
for index, category in enumerate(categories):
|
|
124
|
+
if index:
|
|
125
|
+
lines.append("")
|
|
126
|
+
lines.append(f"{SYMBOL[category.worst_severity]} {category.name}")
|
|
127
|
+
for result in category.results:
|
|
128
|
+
lines.append(f" {SYMBOL[result.severity]} {result.message}")
|
|
129
|
+
if result.fix_hint and result.severity is not Severity.ok:
|
|
130
|
+
lines.append(f" {result.fix_hint}")
|
|
131
|
+
lines.append("")
|
|
132
|
+
lines.append("Doctor found issues that need attention." if has_errors(categories) else "No issues found.")
|
|
133
|
+
if memory_lint:
|
|
134
|
+
lines.append(f"{MEMORY_LINT} is installed at {memory_lint}; content checks (links, index rows, staleness) are its job.")
|
|
135
|
+
return "\n".join(lines) + "\n"
|
|
136
|
+
|
|
137
|
+
|
|
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."""
|
|
140
|
+
output: Dict[str, Any] = {
|
|
141
|
+
"has_errors": has_errors(categories),
|
|
142
|
+
"memory_lint": memory_lint,
|
|
143
|
+
"categories": [],
|
|
144
|
+
}
|
|
145
|
+
for category in categories:
|
|
146
|
+
rows: List[Dict[str, str]] = []
|
|
147
|
+
for result in category.results:
|
|
148
|
+
row = {"name": result.name, "severity": result.severity.value, "message": result.message}
|
|
149
|
+
if result.fix_hint:
|
|
150
|
+
row["fix_hint"] = result.fix_hint
|
|
151
|
+
rows.append(row)
|
|
152
|
+
output["categories"].append(
|
|
153
|
+
{"name": category.name, "worst_severity": category.worst_severity.value, "results": rows}
|
|
154
|
+
)
|
|
155
|
+
return output
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- Org Memory: the debrief store's layout ---------------------------------
|
|
159
|
+
#
|
|
160
|
+
# Setup-level by design: the doctor confirms the store exists, the path layout
|
|
161
|
+
# is canonical, and nothing irregular sits in the namespace. It never opens a
|
|
162
|
+
# record; content and format belong to the writer's read-back at publication
|
|
163
|
+
# and to git history. A failed traversal or classification produces its own
|
|
164
|
+
# non-ok row, never a clean result.
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_org_memory(home: Path) -> Category:
|
|
168
|
+
"""The debrief store under ``org-memory/``: presence, canonical layout, staging leftovers, irregular entries."""
|
|
169
|
+
cat = Category("Org Memory")
|
|
170
|
+
org_memory = layout.org_memory_dir(home)
|
|
171
|
+
try:
|
|
172
|
+
initialized = _is_dir(org_memory)
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
cat.add("org-memory-dir", Severity.error, f"{layout.ORG.pattern}/ — {_not_inspected(exc)}")
|
|
175
|
+
return cat
|
|
176
|
+
if not initialized:
|
|
177
|
+
cat.add("org-memory-dir", Severity.skip, f"{layout.ORG.pattern}/ — not initialized", "Run: agent-memory org init")
|
|
178
|
+
return cat
|
|
179
|
+
|
|
180
|
+
debriefs = org_memory / "debriefs"
|
|
181
|
+
try:
|
|
182
|
+
present = _is_dir(debriefs)
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
cat.add("debriefs-dir", Severity.error, f"{layout.ORG.pattern}/debriefs/ — {_not_inspected(exc)}")
|
|
185
|
+
return cat
|
|
186
|
+
if not present:
|
|
187
|
+
cat.add(
|
|
188
|
+
"debriefs-dir",
|
|
189
|
+
Severity.warn,
|
|
190
|
+
f"{layout.ORG.pattern}/debriefs/ — missing (pre-debrief-store layout)",
|
|
191
|
+
"Run: agent-memory org init",
|
|
192
|
+
)
|
|
193
|
+
return cat
|
|
194
|
+
cat.add("debriefs-dir", Severity.ok, f"{layout.ORG.pattern}/debriefs/ — present")
|
|
195
|
+
|
|
196
|
+
layout_bad: List[str] = []
|
|
197
|
+
staging: List[str] = []
|
|
198
|
+
irregular: List[str] = []
|
|
199
|
+
walk_errors: List[str] = []
|
|
200
|
+
total = 0
|
|
201
|
+
|
|
202
|
+
def _relative(path: Path) -> str:
|
|
203
|
+
try:
|
|
204
|
+
return path.relative_to(debriefs).as_posix() or "."
|
|
205
|
+
except ValueError:
|
|
206
|
+
return str(path)
|
|
207
|
+
|
|
208
|
+
def _walk_error(exc: OSError) -> None:
|
|
209
|
+
# A directory the walk cannot enter hides an unknown number of records;
|
|
210
|
+
# the failure surfaces as its own row.
|
|
211
|
+
location = getattr(exc, "filename", None) or str(debriefs)
|
|
212
|
+
walk_errors.append(f"{_relative(Path(location))}: {exc.__class__.__name__}")
|
|
213
|
+
|
|
214
|
+
entries: List[Path] = []
|
|
215
|
+
# followlinks=False, so a symlinked directory cannot pull a foreign tree into
|
|
216
|
+
# the store; the link itself is flagged below.
|
|
217
|
+
for dirpath, dirnames, filenames in os.walk(debriefs, onerror=_walk_error, followlinks=False):
|
|
218
|
+
current = Path(dirpath)
|
|
219
|
+
kept: List[str] = []
|
|
220
|
+
for name in sorted(dirnames):
|
|
221
|
+
entry = current / name
|
|
222
|
+
try:
|
|
223
|
+
is_link = entry.is_symlink()
|
|
224
|
+
except OSError as exc:
|
|
225
|
+
walk_errors.append(f"{_relative(entry)}: {exc.__class__.__name__}")
|
|
226
|
+
continue
|
|
227
|
+
if is_link:
|
|
228
|
+
irregular.append(f"{_relative(entry)}/ (symlinked directory)")
|
|
229
|
+
else:
|
|
230
|
+
kept.append(name)
|
|
231
|
+
dirnames[:] = kept
|
|
232
|
+
entries.extend(current / name for name in filenames)
|
|
233
|
+
|
|
234
|
+
for file_path in sorted(entries):
|
|
235
|
+
rel = _relative(file_path)
|
|
236
|
+
if rel == ".gitkeep":
|
|
237
|
+
continue
|
|
238
|
+
# Writer staging artifacts (.stage.<name>.<nonce>) sit outside the
|
|
239
|
+
# canonical namespace; a lingering one means an interrupted publication.
|
|
240
|
+
if file_path.name.startswith(".stage."):
|
|
241
|
+
staging.append(rel)
|
|
242
|
+
continue
|
|
243
|
+
# The namespace holds regular files reached without following links;
|
|
244
|
+
# a classification failure surfaces, never raises.
|
|
245
|
+
try:
|
|
246
|
+
if file_path.is_symlink():
|
|
247
|
+
irregular.append(f"{rel} (symlink)")
|
|
248
|
+
continue
|
|
249
|
+
regular = file_path.is_file()
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
walk_errors.append(f"{rel}: {exc.__class__.__name__}")
|
|
252
|
+
continue
|
|
253
|
+
if not regular:
|
|
254
|
+
irregular.append(f"{rel} (not a regular file)")
|
|
255
|
+
continue
|
|
256
|
+
total += 1
|
|
257
|
+
parts = rel.split("/")
|
|
258
|
+
match = DEBRIEF_FILENAME_RE.match(parts[-1]) if len(parts) == 4 else None
|
|
259
|
+
date_valid = False
|
|
260
|
+
if match is not None:
|
|
261
|
+
try:
|
|
262
|
+
dt.datetime.strptime(match.group("date"), "%Y%m%d")
|
|
263
|
+
date_valid = True
|
|
264
|
+
except ValueError:
|
|
265
|
+
pass
|
|
266
|
+
if (
|
|
267
|
+
match is None
|
|
268
|
+
or not date_valid
|
|
269
|
+
or not _valid_project_segment(parts[0])
|
|
270
|
+
or parts[1] != match.group("date")[0:4]
|
|
271
|
+
or parts[2] != match.group("date")[4:6]
|
|
272
|
+
):
|
|
273
|
+
layout_bad.append(rel)
|
|
274
|
+
|
|
275
|
+
if staging:
|
|
276
|
+
cat.add(
|
|
277
|
+
"debriefs-staging",
|
|
278
|
+
Severity.warn,
|
|
279
|
+
f"{len(staging)} lingering writer staging artifact(s) (interrupted publication): {_summarize(staging)}",
|
|
280
|
+
"The owning writer removes or adopts its stale staging files on retry",
|
|
281
|
+
)
|
|
282
|
+
if irregular:
|
|
283
|
+
cat.add(
|
|
284
|
+
"debriefs-irregular",
|
|
285
|
+
Severity.error,
|
|
286
|
+
f"{len(irregular)} non-regular entr(ies) under debriefs/ "
|
|
287
|
+
f"(the store holds regular files, never symlinks): {_summarize(irregular)}",
|
|
288
|
+
)
|
|
289
|
+
if walk_errors:
|
|
290
|
+
cat.add(
|
|
291
|
+
"debriefs-unreadable",
|
|
292
|
+
Severity.error,
|
|
293
|
+
f"{len(walk_errors)} entr(ies) under debriefs/ could not be inspected "
|
|
294
|
+
f"(setup check incomplete): {_summarize(walk_errors)}",
|
|
295
|
+
)
|
|
296
|
+
if total == 0:
|
|
297
|
+
if not walk_errors:
|
|
298
|
+
cat.add("debriefs-layout", Severity.ok, "debriefs/ — empty store, nothing to validate")
|
|
299
|
+
return cat
|
|
300
|
+
if layout_bad:
|
|
301
|
+
cat.add(
|
|
302
|
+
"debriefs-layout",
|
|
303
|
+
Severity.error,
|
|
304
|
+
f"{len(layout_bad)} of {total} debrief file(s) outside the canonical "
|
|
305
|
+
f"<project>/<YYYY>/<MM>/<YYYYMMDD>-<agent>-<session>.md layout: {_summarize(layout_bad)}",
|
|
306
|
+
"Move or rename to the canonical path; never rewrite contents",
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
cat.add("debriefs-layout", Severity.ok, f"{total} debrief file(s) — canonical layout")
|
|
310
|
+
return cat
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _valid_project_segment(name: str) -> bool:
|
|
314
|
+
try:
|
|
315
|
+
layout.validate_project_name(name)
|
|
316
|
+
except ValueError:
|
|
317
|
+
return False
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- Memory Sync: the marker, the allowlist, and the repository ------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def check_memory_sync(
|
|
325
|
+
home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None
|
|
326
|
+
) -> Category:
|
|
327
|
+
"""The sync setup and the repository's state, through git alone; nothing is changed."""
|
|
328
|
+
cat = Category("Memory Sync")
|
|
329
|
+
marker = layout.MARKER_FILE
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
configured = _is_file(sync.marker_path(home))
|
|
333
|
+
except OSError as exc:
|
|
334
|
+
cat.add("memory-marker", Severity.warn, f"{marker} — {_not_inspected(exc)}")
|
|
335
|
+
return cat
|
|
336
|
+
if not configured:
|
|
337
|
+
cat.add(
|
|
338
|
+
"memory-marker",
|
|
339
|
+
Severity.skip,
|
|
340
|
+
f"{marker} — not configured; memory sync hooks are disabled",
|
|
341
|
+
"Run: agent-memory enable [--remote URL]",
|
|
342
|
+
)
|
|
343
|
+
return cat
|
|
344
|
+
cat.add("memory-marker", Severity.ok, f"{marker} — present")
|
|
345
|
+
|
|
346
|
+
if not sync.is_git_repo(home, runner):
|
|
347
|
+
cat.add(
|
|
348
|
+
"memory-git",
|
|
349
|
+
Severity.warn,
|
|
350
|
+
f"{marker} — present, but {home} is not a git repository",
|
|
351
|
+
"Run `agent-memory enable`, or `agent-memory disable` to remove the marker",
|
|
352
|
+
)
|
|
353
|
+
return cat
|
|
354
|
+
enclosing = enclosing_repository(home, runner)
|
|
355
|
+
if enclosing is not None:
|
|
356
|
+
# The sync verbs refuse this home; reading the enclosing repository's state as the home's would be wrong.
|
|
357
|
+
cat.add(
|
|
358
|
+
"memory-git",
|
|
359
|
+
Severity.warn,
|
|
360
|
+
f"{marker} — present, but {home} is inside the git worktree {enclosing}; "
|
|
361
|
+
"a memory home must be the root of its own repository",
|
|
362
|
+
"Move the home out of the enclosing repository, or `agent-memory disable` to remove the marker",
|
|
363
|
+
)
|
|
364
|
+
return cat
|
|
365
|
+
|
|
366
|
+
root_gitignore = home / layout.GITIGNORE_FILE
|
|
367
|
+
try:
|
|
368
|
+
present = _is_file(root_gitignore)
|
|
369
|
+
except OSError as exc:
|
|
370
|
+
cat.add("root-gitignore", Severity.warn, f"{layout.GITIGNORE_FILE} — {_not_inspected(exc)}")
|
|
371
|
+
present = None
|
|
372
|
+
if present is False:
|
|
373
|
+
cat.add(
|
|
374
|
+
"root-gitignore",
|
|
375
|
+
Severity.warn,
|
|
376
|
+
f"{layout.GITIGNORE_FILE} — missing canonical memory allowlist",
|
|
377
|
+
"Run: agent-memory enable",
|
|
378
|
+
)
|
|
379
|
+
elif present:
|
|
380
|
+
try:
|
|
381
|
+
content = _normalize_gitignore(root_gitignore.read_text(encoding="utf-8"))
|
|
382
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
383
|
+
cat.add(
|
|
384
|
+
"root-gitignore",
|
|
385
|
+
Severity.warn,
|
|
386
|
+
f"{layout.GITIGNORE_FILE} — could not be read: {_failure_text(exc)}",
|
|
387
|
+
_read_hint(exc),
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
if content == layout.gitignore_text():
|
|
391
|
+
cat.add("root-gitignore", Severity.ok, f"{layout.GITIGNORE_FILE} — canonical memory allowlist")
|
|
392
|
+
elif sync.gitignore_has_managed_block(content):
|
|
393
|
+
kept = len(content.splitlines()) - len(layout.gitignore_text().splitlines())
|
|
394
|
+
cat.add(
|
|
395
|
+
"root-gitignore",
|
|
396
|
+
Severity.ok,
|
|
397
|
+
f"{layout.GITIGNORE_FILE} — canonical memory allowlist as a managed block; {kept} other line(s) kept",
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
cat.add(
|
|
401
|
+
"root-gitignore",
|
|
402
|
+
Severity.warn,
|
|
403
|
+
f"{layout.GITIGNORE_FILE} — drifted from canonical memory allowlist",
|
|
404
|
+
"Run `agent-memory enable` to restore the managed allowlist block",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
tracked: Optional[List[str]] = None
|
|
408
|
+
try:
|
|
409
|
+
tracked = _ls_files(home, runner)
|
|
410
|
+
outside = [path for path in tracked if not layout.is_allowed_memory_path(path)]
|
|
411
|
+
except _GitFailed as exc:
|
|
412
|
+
cat.add("tracked-allowlist", Severity.warn, f"tracked allowlist check failed: {exc}")
|
|
413
|
+
else:
|
|
414
|
+
if outside:
|
|
415
|
+
cat.add(
|
|
416
|
+
"tracked-allowlist",
|
|
417
|
+
Severity.warn,
|
|
418
|
+
f"{len(outside)} tracked file(s) outside memory allowlist: {_summarize(outside)}",
|
|
419
|
+
"Remove runtime state from the memory repo index",
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
cat.add("tracked-allowlist", Severity.ok, f"tracked files — {len(tracked)} inside memory allowlist")
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
untracked = [path for path in _ls_files(home, runner, "--others", "--exclude-standard") if layout.is_allowed_memory_path(path)]
|
|
426
|
+
except _GitFailed as exc:
|
|
427
|
+
cat.add("untracked-memory", Severity.warn, f"untracked memory check failed: {exc}")
|
|
428
|
+
else:
|
|
429
|
+
if untracked:
|
|
430
|
+
cat.add(
|
|
431
|
+
"untracked-memory",
|
|
432
|
+
Severity.warn,
|
|
433
|
+
f"{len(untracked)} untracked memory-shaped file(s): {_summarize(untracked)}",
|
|
434
|
+
"Run: agent-memory push",
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
cat.add("untracked-memory", Severity.ok, "untracked memory files — none")
|
|
438
|
+
|
|
439
|
+
state: Optional[GitState]
|
|
440
|
+
try:
|
|
441
|
+
state = sync.git_state(home, runner=runner, fetch=True)
|
|
442
|
+
except sync.SyncError as exc:
|
|
443
|
+
cat.add("working-tree", Severity.warn, f"memory git state check failed: {exc}")
|
|
444
|
+
state = None
|
|
445
|
+
|
|
446
|
+
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")
|
|
456
|
+
|
|
457
|
+
text = f"sync state — {sync_state_text(state)}"
|
|
458
|
+
if not state.has_remote:
|
|
459
|
+
cat.add("sync-state", Severity.ok, text)
|
|
460
|
+
cat.add("remote", Severity.skip, "remote — skipped; local-only memory repo")
|
|
461
|
+
elif state.fetch_failed:
|
|
462
|
+
cat.add("sync-state", Severity.warn, text, "Check network access and remote permissions")
|
|
463
|
+
cat.add("remote", Severity.warn, "remote — not reachable", "Check network access and remote permissions")
|
|
464
|
+
else:
|
|
465
|
+
if not state.has_upstream:
|
|
466
|
+
cat.add("sync-state", Severity.warn, text, "Run: git -C <home> push -u <remote> <branch>")
|
|
467
|
+
elif state.diverged:
|
|
468
|
+
cat.add("sync-state", Severity.warn, text, "Resolve manually; agent-memory never merges memory")
|
|
469
|
+
elif state.behind:
|
|
470
|
+
cat.add("sync-state", Severity.warn, text, "Run: agent-memory pull")
|
|
471
|
+
elif state.ahead:
|
|
472
|
+
cat.add("sync-state", Severity.warn, text, "Run: agent-memory push")
|
|
473
|
+
else:
|
|
474
|
+
cat.add("sync-state", Severity.ok, text)
|
|
475
|
+
cat.add("remote", Severity.ok, "remote — reachable")
|
|
476
|
+
|
|
477
|
+
if not _has_commits(home, runner):
|
|
478
|
+
cat.add("last-commit", Severity.warn, "last commit — none", "Run: agent-memory push")
|
|
479
|
+
else:
|
|
480
|
+
age_days = _last_commit_age_days(home, runner, now=now)
|
|
481
|
+
if age_days is None:
|
|
482
|
+
cat.add("last-commit", Severity.warn, "last commit — timestamp unavailable")
|
|
483
|
+
elif age_days > STALE_MEMORY_DAYS:
|
|
484
|
+
cat.add("last-commit", Severity.warn, f"last commit — stale ({age_days} day(s) old)", "Run: agent-memory push")
|
|
485
|
+
else:
|
|
486
|
+
cat.add("last-commit", Severity.ok, f"last commit — fresh ({age_days} day(s) old)")
|
|
487
|
+
|
|
488
|
+
if tracked is not None:
|
|
489
|
+
agents_tracked = [
|
|
490
|
+
path
|
|
491
|
+
for path in tracked
|
|
492
|
+
if path.startswith("agents/") or (path.startswith(f"{layout.PROJECTS_DIR}/") and "/agents/" in path)
|
|
493
|
+
]
|
|
494
|
+
if agents_tracked:
|
|
495
|
+
cat.add(
|
|
496
|
+
"agents-tracked",
|
|
497
|
+
Severity.warn,
|
|
498
|
+
f"{len(agents_tracked)} agents/ file(s) tracked: {_summarize(agents_tracked)}",
|
|
499
|
+
"Remove per-instance agent state from the memory repo",
|
|
500
|
+
)
|
|
501
|
+
else:
|
|
502
|
+
cat.add("agents-tracked", Severity.ok, "agents/ tracked files — none")
|
|
503
|
+
|
|
504
|
+
overlays, failed = _overlay_gitignores(home)
|
|
505
|
+
escaping: List[str] = []
|
|
506
|
+
for overlay in overlays:
|
|
507
|
+
rel = _relative_to_home(home, overlay)
|
|
508
|
+
try:
|
|
509
|
+
patterns = _escaping_overlay_patterns(overlay)
|
|
510
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
511
|
+
failed.append(f"{rel}: {_failure_text(exc)}")
|
|
512
|
+
continue
|
|
513
|
+
escaping.extend(f"{rel}: {pattern}" for pattern in patterns)
|
|
514
|
+
if failed:
|
|
515
|
+
# An overlay the doctor could not find or read may still escape memory/**;
|
|
516
|
+
# the row says the check is incomplete rather than counting it safe.
|
|
517
|
+
cat.add(
|
|
518
|
+
"memory-overlays",
|
|
519
|
+
Severity.warn,
|
|
520
|
+
f"{len(failed)} memory .gitignore overlay location(s) could not be inspected "
|
|
521
|
+
f"(overlay check incomplete): {_summarize(failed)}",
|
|
522
|
+
"Restore read access under projects/ (or re-encode the file as UTF-8) and re-run",
|
|
523
|
+
)
|
|
524
|
+
elif escaping:
|
|
525
|
+
cat.add(
|
|
526
|
+
"memory-overlays",
|
|
527
|
+
Severity.warn,
|
|
528
|
+
f"memory .gitignore overlays can escape memory/**: {_summarize(escaping)}",
|
|
529
|
+
"Remove overlay unignore patterns containing '..'",
|
|
530
|
+
)
|
|
531
|
+
else:
|
|
532
|
+
cat.add("memory-overlays", Severity.ok, f"memory .gitignore overlays — {len(overlays)} safe")
|
|
533
|
+
|
|
534
|
+
return cat
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def enclosing_repository(home: Path, runner: Optional[GitRunner] = None) -> Optional[Path]:
|
|
538
|
+
"""The worktree root when ``home`` sits inside a repository that is not its own; ``None`` when it is the root."""
|
|
539
|
+
root = sync.worktree_root(home, runner)
|
|
540
|
+
if root is None or root.resolve() == home.resolve():
|
|
541
|
+
return None
|
|
542
|
+
return root
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def sync_state_text(state: GitState) -> str:
|
|
546
|
+
"""One phrase for where the repository stands; ``status`` and ``doctor`` share it."""
|
|
547
|
+
if not state.has_remote:
|
|
548
|
+
return "local-only; no remote configured"
|
|
549
|
+
if state.fetch_failed:
|
|
550
|
+
return f"remote fetch failed: {state.fetch_output}"
|
|
551
|
+
if not state.has_upstream:
|
|
552
|
+
return "remote exists but no upstream branch is configured"
|
|
553
|
+
if state.diverged:
|
|
554
|
+
return f"DIVERGED from upstream ({state.ahead} ahead, {state.behind} behind)"
|
|
555
|
+
if state.behind:
|
|
556
|
+
return f"BEHIND upstream by {state.behind} commit(s)"
|
|
557
|
+
if state.ahead:
|
|
558
|
+
return f"ahead by {state.ahead} unpushed commit(s)"
|
|
559
|
+
return "synced with upstream"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# --- git readout helpers the doctor alone needs -----------------------------
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class _GitFailed(Exception):
|
|
566
|
+
"""A git readout the doctor needs did not run; the row says so."""
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _git(home: Path, args: Sequence[str], runner: Optional[GitRunner]) -> GitResult:
|
|
570
|
+
return (runner or run_git)(args, cwd=home)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _ls_files(home: Path, runner: Optional[GitRunner], *options: str) -> List[str]:
|
|
574
|
+
result = _git(home, ["ls-files", *options], runner)
|
|
575
|
+
if not result.ok:
|
|
576
|
+
raise _GitFailed(f"{' '.join(['git', 'ls-files', *options])} failed: {result.output}")
|
|
577
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _has_commits(home: Path, runner: Optional[GitRunner]) -> bool:
|
|
581
|
+
return _git(home, ["rev-parse", "--verify", "HEAD"], runner).ok
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _last_commit_age_days(home: Path, runner: Optional[GitRunner], *, now: Optional[dt.datetime]) -> Optional[int]:
|
|
585
|
+
result = _git(home, ["log", "-1", "--format=%ct"], runner)
|
|
586
|
+
if not result.ok:
|
|
587
|
+
return None
|
|
588
|
+
try:
|
|
589
|
+
timestamp = int(result.stdout.strip())
|
|
590
|
+
except ValueError:
|
|
591
|
+
return None
|
|
592
|
+
current = now or dt.datetime.now(dt.timezone.utc)
|
|
593
|
+
committed = dt.datetime.fromtimestamp(timestamp, tz=dt.timezone.utc)
|
|
594
|
+
return max(0, int((current - committed).total_seconds() // 86400))
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _normalize_gitignore(text: str) -> str:
|
|
598
|
+
return text.replace("\r\n", "\n")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# --- filesystem probes ------------------------------------------------------
|
|
602
|
+
#
|
|
603
|
+
# pathlib's exists/is_dir/is_file/glob answer False (or skip the subtree) when
|
|
604
|
+
# the probe is denied, which would let an unreadable path read as absent, and
|
|
605
|
+
# absent as fine. These probes return the answer when there is one and raise
|
|
606
|
+
# when there is not; the caller's row says the check is incomplete.
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _inspect(path: Path) -> Optional[os.stat_result]:
|
|
610
|
+
"""``stat`` following symlinks: the result, ``None`` when the path is absent, ``OSError`` when it was denied."""
|
|
611
|
+
try:
|
|
612
|
+
return os.stat(path)
|
|
613
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _is_dir(path: Path) -> bool:
|
|
618
|
+
result = _inspect(path)
|
|
619
|
+
return result is not None and stat.S_ISDIR(result.st_mode)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _is_file(path: Path) -> bool:
|
|
623
|
+
result = _inspect(path)
|
|
624
|
+
return result is not None and stat.S_ISREG(result.st_mode)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _failure_text(exc: Exception) -> str:
|
|
628
|
+
if isinstance(exc, UnicodeDecodeError):
|
|
629
|
+
return "not valid UTF-8"
|
|
630
|
+
return getattr(exc, "strerror", None) or exc.__class__.__name__
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _not_inspected(exc: OSError) -> str:
|
|
634
|
+
return f"could not be inspected (setup check incomplete): {_failure_text(exc)}"
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _read_hint(exc: Exception) -> str:
|
|
638
|
+
return "Re-encode the file as UTF-8" if isinstance(exc, UnicodeDecodeError) else ""
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _relative_to_home(home: Path, path: Path) -> str:
|
|
642
|
+
try:
|
|
643
|
+
return path.relative_to(home).as_posix()
|
|
644
|
+
except ValueError:
|
|
645
|
+
return str(path)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _overlay_gitignores(home: Path) -> Tuple[List[Path], List[str]]:
|
|
649
|
+
"""The project overlays ``projects/<p>/memory/.gitignore`` that exist, and the locations
|
|
650
|
+
the discovery was denied, as ``<path>: <reason>``.
|
|
651
|
+
|
|
652
|
+
A bounded walk over the tier pattern rather than ``Path.glob``: glob swallows a traversal
|
|
653
|
+
it is denied, so an unreadable project or memory directory would read as "no overlay".
|
|
654
|
+
Here a directory that is absent (or not a directory) is the only thing that means no
|
|
655
|
+
overlay; every other failure goes back to the caller's row.
|
|
656
|
+
"""
|
|
657
|
+
found: List[Path] = []
|
|
658
|
+
failed: List[str] = []
|
|
659
|
+
candidates = [home]
|
|
660
|
+
for part in layout.PROJECT.parts:
|
|
661
|
+
expanded: List[Path] = []
|
|
662
|
+
for base in candidates:
|
|
663
|
+
if part != layout.WILDCARD:
|
|
664
|
+
expanded.append(base / part)
|
|
665
|
+
continue
|
|
666
|
+
try:
|
|
667
|
+
with os.scandir(base) as entries:
|
|
668
|
+
names = sorted(entry.name for entry in entries)
|
|
669
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
670
|
+
continue
|
|
671
|
+
except OSError as exc:
|
|
672
|
+
failed.append(f"{_relative_to_home(home, base)}/: {_failure_text(exc)}")
|
|
673
|
+
continue
|
|
674
|
+
expanded.extend(base / name for name in names)
|
|
675
|
+
candidates = expanded
|
|
676
|
+
for directory in candidates:
|
|
677
|
+
overlay = directory / layout.GITIGNORE_FILE
|
|
678
|
+
try:
|
|
679
|
+
# lstat, so a dangling overlay symlink is found and then fails to read, never skipped.
|
|
680
|
+
os.lstat(overlay)
|
|
681
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
682
|
+
continue
|
|
683
|
+
except OSError as exc:
|
|
684
|
+
failed.append(f"{_relative_to_home(home, overlay)}: {_failure_text(exc)}")
|
|
685
|
+
continue
|
|
686
|
+
found.append(overlay)
|
|
687
|
+
return sorted(found), failed
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _escaping_overlay_patterns(path: Path) -> List[str]:
|
|
691
|
+
"""Unignore patterns in a project overlay whose path climbs out of ``memory/``."""
|
|
692
|
+
bad: List[str] = []
|
|
693
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
694
|
+
line = raw.strip()
|
|
695
|
+
if not line or line.startswith("#") or not line.startswith("!"):
|
|
696
|
+
continue
|
|
697
|
+
pattern = line[1:].strip()
|
|
698
|
+
parts = [part for part in pattern.replace("\\", "/").split("/") if part]
|
|
699
|
+
if ".." in parts:
|
|
700
|
+
bad.append(raw)
|
|
701
|
+
return bad
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _summarize(paths: Sequence[str], *, limit: int = 3) -> str:
|
|
705
|
+
if not paths:
|
|
706
|
+
return ""
|
|
707
|
+
shown = ", ".join(paths[:limit])
|
|
708
|
+
if len(paths) > limit:
|
|
709
|
+
shown += f", +{len(paths) - limit} more"
|
|
710
|
+
return shown
|