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/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