memgit 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {memgit-0.3.0 → memgit-0.4.0}/PKG-INFO +17 -7
  2. {memgit-0.3.0 → memgit-0.4.0}/README.md +16 -6
  3. {memgit-0.3.0 → memgit-0.4.0}/memgit/__init__.py +1 -1
  4. {memgit-0.3.0 → memgit-0.4.0}/memgit/cli.py +205 -75
  5. memgit-0.4.0/memgit/gitdigest.py +158 -0
  6. memgit-0.4.0/memgit/hooks.py +212 -0
  7. {memgit-0.3.0 → memgit-0.4.0}/memgit/importer.py +11 -31
  8. {memgit-0.3.0 → memgit-0.4.0}/memgit/mcp_server.py +33 -5
  9. memgit-0.4.0/memgit/project.py +90 -0
  10. {memgit-0.3.0 → memgit-0.4.0}/memgit/repo.py +44 -9
  11. {memgit-0.3.0 → memgit-0.4.0}/memgit/scorer.py +13 -4
  12. {memgit-0.3.0 → memgit-0.4.0}/memgit/toon.py +33 -6
  13. {memgit-0.3.0 → memgit-0.4.0}/memgit.egg-info/PKG-INFO +17 -7
  14. {memgit-0.3.0 → memgit-0.4.0}/memgit.egg-info/SOURCES.txt +5 -1
  15. {memgit-0.3.0 → memgit-0.4.0}/pyproject.toml +1 -1
  16. {memgit-0.3.0 → memgit-0.4.0}/tests/test_v030.py +79 -0
  17. memgit-0.4.0/tests/test_v040.py +371 -0
  18. {memgit-0.3.0 → memgit-0.4.0}/LICENSE +0 -0
  19. {memgit-0.3.0 → memgit-0.4.0}/memgit/cloud/__init__.py +0 -0
  20. {memgit-0.3.0 → memgit-0.4.0}/memgit/cloud/client.py +0 -0
  21. {memgit-0.3.0 → memgit-0.4.0}/memgit/cloud/commands.py +0 -0
  22. {memgit-0.3.0 → memgit-0.4.0}/memgit/cloud/crypto.py +0 -0
  23. {memgit-0.3.0 → memgit-0.4.0}/memgit/cloud/state.py +0 -0
  24. {memgit-0.3.0 → memgit-0.4.0}/memgit/cloud/sync.py +0 -0
  25. {memgit-0.3.0 → memgit-0.4.0}/memgit/graph.py +0 -0
  26. {memgit-0.3.0 → memgit-0.4.0}/memgit/http_server.py +0 -0
  27. {memgit-0.3.0 → memgit-0.4.0}/memgit/models.py +0 -0
  28. {memgit-0.3.0 → memgit-0.4.0}/memgit/store.py +0 -0
  29. {memgit-0.3.0 → memgit-0.4.0}/memgit/tokens.py +0 -0
  30. {memgit-0.3.0 → memgit-0.4.0}/memgit.egg-info/dependency_links.txt +0 -0
  31. {memgit-0.3.0 → memgit-0.4.0}/memgit.egg-info/entry_points.txt +0 -0
  32. {memgit-0.3.0 → memgit-0.4.0}/memgit.egg-info/requires.txt +0 -0
  33. {memgit-0.3.0 → memgit-0.4.0}/memgit.egg-info/top_level.txt +0 -0
  34. {memgit-0.3.0 → memgit-0.4.0}/setup.cfg +0 -0
  35. {memgit-0.3.0 → memgit-0.4.0}/tests/test_advanced.py +0 -0
  36. {memgit-0.3.0 → memgit-0.4.0}/tests/test_setup.py +0 -0
  37. {memgit-0.3.0 → memgit-0.4.0}/tests/test_store_repo.py +0 -0
  38. {memgit-0.3.0 → memgit-0.4.0}/tests/test_toon.py +0 -0
  39. {memgit-0.3.0 → memgit-0.4.0}/tests/test_v020.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://memgit.dev
@@ -193,10 +193,10 @@ Memory tools have a cold-start problem: install one halfway through a project an
193
193
 
194
194
  ```bash
195
195
  cd your-project
196
- memgit onboard # prints the bootstrap brief
196
+ memgit onboard # mines the repo, prints the bootstrap brief
197
197
  ```
198
198
 
199
- The brief tells your AI agent exactly what to do: read the README/docs/manifests and recent git history, extract 10–20 durable facts (purpose, architecture, conventions, current state, gotchas), save each as a typed memory, and checkpoint the seed set. Paste it into a session — or don't: if the AI searches memory in a project that has none, the MCP server itself replies with the bootstrap instructions instead of a bare "no results."
199
+ `onboard` first extracts a **repo digest** deterministically — git history (recent commit subjects, hot files/directories by churn, authors, branch, tags), detected stack from manifests, and the docs worth reading — using bounded, read-only probes that stay near-instant even on huge repositories. The brief then tells your AI agent exactly what to do with it: read only the listed files (no tree crawling), extract 10–20 durable facts (purpose, architecture, conventions, current state, gotchas), save each as a typed memory, and checkpoint the seed set. Paste it into a session — or don't: if the AI searches memory in a project that has none, the MCP server itself replies with the bootstrap instructions instead of a bare "no results."
200
200
 
201
201
  Memories are **project-scoped**: each carries the workspace it belongs to, searches boost the project you're standing in (global rules still surface), and the resume digest leads with *your current project's* recent work — not whatever repo you touched last night.
202
202
 
@@ -212,13 +212,22 @@ memgit resume --plain # plain text, for piping into an AI context
212
212
  memgit resume --json # for tooling
213
213
  ```
214
214
 
215
- Wire it into Claude Code so every new session **starts** with this digest in context — no tool call, no judgment required:
215
+ Wire it into Claude Code so memory becomes **automatic** — no tool call, no judgment required:
216
216
 
217
217
  ```bash
218
- memgit setup hooks # installs a SessionStart hook (~/.claude/settings.json)
218
+ memgit setup hooks # installs all four hooks (~/.claude/settings.json)
219
219
  ```
220
220
 
221
- The digest is deliberately bounded (~350 tokens measured on a 500-memory store): rules are clipped, the critical list is capped, and full text is one `get_memory` call away.
221
+ | Hook | What it enforces |
222
+ |---|---|
223
+ | `SessionStart` | every session opens with the resume digest in context |
224
+ | `UserPromptSubmit` | each prompt is BM25-matched against the store; relevant memories are injected (silent when nothing clears the relevance bar; never repeats within a session) — `--no-recall` to skip |
225
+ | `Stop` (guard) | a session that did real work but saved nothing gets ONE nudge to save durable facts before finishing — `--no-guard` to skip |
226
+ | `Stop` (sync) | markdown memories are checkpointed asynchronously at session end |
227
+
228
+ Why hooks and not just good tool descriptions? We measured it: across 166 real sessions, hook-injected context was delivered in **100%** of them while voluntary memory-tool calls happened in **6%**. What a hook enforces happens.
229
+
230
+ The resume digest is deliberately bounded (~350 tokens measured on a 500-memory store): rules are clipped, the critical list is capped, and full text is one `get_memory` call away.
222
231
 
223
232
  ---
224
233
 
@@ -322,7 +331,8 @@ memgit setup windsurf
322
331
  memgit setup cline
323
332
  memgit setup continue
324
333
  memgit setup gemini-cli
325
- memgit setup hooks # Claude Code SessionStart hook auto-inject resume digest
334
+ memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
335
+ # capture guard + auto-sync at stop (--no-recall / --no-guard)
326
336
 
327
337
  # Server
328
338
  memgit serve # MCP stdio (Claude Code, Cursor, Windsurf, Cline)
@@ -159,10 +159,10 @@ Memory tools have a cold-start problem: install one halfway through a project an
159
159
 
160
160
  ```bash
161
161
  cd your-project
162
- memgit onboard # prints the bootstrap brief
162
+ memgit onboard # mines the repo, prints the bootstrap brief
163
163
  ```
164
164
 
165
- The brief tells your AI agent exactly what to do: read the README/docs/manifests and recent git history, extract 10–20 durable facts (purpose, architecture, conventions, current state, gotchas), save each as a typed memory, and checkpoint the seed set. Paste it into a session — or don't: if the AI searches memory in a project that has none, the MCP server itself replies with the bootstrap instructions instead of a bare "no results."
165
+ `onboard` first extracts a **repo digest** deterministically — git history (recent commit subjects, hot files/directories by churn, authors, branch, tags), detected stack from manifests, and the docs worth reading — using bounded, read-only probes that stay near-instant even on huge repositories. The brief then tells your AI agent exactly what to do with it: read only the listed files (no tree crawling), extract 10–20 durable facts (purpose, architecture, conventions, current state, gotchas), save each as a typed memory, and checkpoint the seed set. Paste it into a session — or don't: if the AI searches memory in a project that has none, the MCP server itself replies with the bootstrap instructions instead of a bare "no results."
166
166
 
167
167
  Memories are **project-scoped**: each carries the workspace it belongs to, searches boost the project you're standing in (global rules still surface), and the resume digest leads with *your current project's* recent work — not whatever repo you touched last night.
168
168
 
@@ -178,13 +178,22 @@ memgit resume --plain # plain text, for piping into an AI context
178
178
  memgit resume --json # for tooling
179
179
  ```
180
180
 
181
- Wire it into Claude Code so every new session **starts** with this digest in context — no tool call, no judgment required:
181
+ Wire it into Claude Code so memory becomes **automatic** — no tool call, no judgment required:
182
182
 
183
183
  ```bash
184
- memgit setup hooks # installs a SessionStart hook (~/.claude/settings.json)
184
+ memgit setup hooks # installs all four hooks (~/.claude/settings.json)
185
185
  ```
186
186
 
187
- The digest is deliberately bounded (~350 tokens measured on a 500-memory store): rules are clipped, the critical list is capped, and full text is one `get_memory` call away.
187
+ | Hook | What it enforces |
188
+ |---|---|
189
+ | `SessionStart` | every session opens with the resume digest in context |
190
+ | `UserPromptSubmit` | each prompt is BM25-matched against the store; relevant memories are injected (silent when nothing clears the relevance bar; never repeats within a session) — `--no-recall` to skip |
191
+ | `Stop` (guard) | a session that did real work but saved nothing gets ONE nudge to save durable facts before finishing — `--no-guard` to skip |
192
+ | `Stop` (sync) | markdown memories are checkpointed asynchronously at session end |
193
+
194
+ Why hooks and not just good tool descriptions? We measured it: across 166 real sessions, hook-injected context was delivered in **100%** of them while voluntary memory-tool calls happened in **6%**. What a hook enforces happens.
195
+
196
+ The resume digest is deliberately bounded (~350 tokens measured on a 500-memory store): rules are clipped, the critical list is capped, and full text is one `get_memory` call away.
188
197
 
189
198
  ---
190
199
 
@@ -288,7 +297,8 @@ memgit setup windsurf
288
297
  memgit setup cline
289
298
  memgit setup continue
290
299
  memgit setup gemini-cli
291
- memgit setup hooks # Claude Code SessionStart hook auto-inject resume digest
300
+ memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
301
+ # capture guard + auto-sync at stop (--no-recall / --no-guard)
292
302
 
293
303
  # Server
294
304
  memgit serve # MCP stdio (Claude Code, Cursor, Windsurf, Cline)
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -29,11 +29,10 @@ def _require_repo() -> Repository:
29
29
 
30
30
  # ── Root group ────────────────────────────────────────────────────────────────
31
31
 
32
- try:
33
- from importlib.metadata import version as _pkg_version
34
- _version = _pkg_version('memgit')
35
- except Exception:
36
- _version = '0.1.2'
32
+ # Source of truth is the code, not dist metadata: editable installs keep
33
+ # whatever metadata version existed at `pip install -e` time (observed: a
34
+ # 0.3.1 checkout reporting 0.1.0 via importlib.metadata).
35
+ from . import __version__ as _version
37
36
 
38
37
 
39
38
  @click.group()
@@ -127,7 +126,8 @@ def init(directory):
127
126
  @click.option('--body', '-b', default=None,
128
127
  help='Full long-form detail (multi-line ok, or "-" to read stdin)')
129
128
  @click.option('--project', '-P', default=None,
130
- help='Project this memory belongs to (see `memgit list`)')
129
+ help='Project this memory belongs to (default: derived from the '
130
+ 'current directory; pass "" for a global memory)')
131
131
  def add(slug, rule, type_code, why, when, tags, priority, body, project):
132
132
  """Add or update a mnemonic.
133
133
 
@@ -138,6 +138,13 @@ def add(slug, rule, type_code, why, when, tags, priority, body, project):
138
138
  tag_list = [t.strip() for t in tags.split(',')] if tags else []
139
139
  if body == '-':
140
140
  body = sys.stdin.read().strip() or None
141
+ # Same scoping semantics as MCP save_memory: absent → this workspace,
142
+ # explicit empty → deliberately global.
143
+ if project is None:
144
+ from .project import project_label_from_path
145
+ project = project_label_from_path(Path.cwd())
146
+ elif not project.strip():
147
+ project = None
141
148
 
142
149
  m = Mnemonic(
143
150
  type_code=type_code,
@@ -152,7 +159,8 @@ def add(slug, rule, type_code, why, when, tags, priority, body, project):
152
159
  project=project,
153
160
  )
154
161
  sha = repo.add(m)
155
- console.print(f'[green]staged[/green] {slug} [{sha[:8]}]')
162
+ from rich.markup import escape as _mesc
163
+ console.print(f'[green]staged[/green] {_mesc(m.slug)} {_mesc("[" + sha[:8] + "]")}')
156
164
 
157
165
 
158
166
  # ── remove ────────────────────────────────────────────────────────────────────
@@ -355,12 +363,42 @@ def _format_resume_plain(ctx: dict) -> str:
355
363
  if ctx.get('maintenance'):
356
364
  lines.append('')
357
365
  lines.append(f'## Maintenance needed\n- {ctx["maintenance"]}')
366
+ if ctx.get('project_is_new'):
367
+ lines.append('')
368
+ lines.append(
369
+ f'## This project has no memories yet ({ctx["project"]})\n'
370
+ '- memgit was adopted mid-project: nothing above is specific to this '
371
+ 'workspace. Bootstrap it once — run `memgit onboard` for a repo '
372
+ 'digest + seeding brief, then save 10-20 durable facts '
373
+ '(purpose, architecture, conventions, state, gotchas) via save_memory.'
374
+ )
358
375
  lines.append('')
359
376
  lines.append('(Check work-in-flight and the last checkpoints before assuming state; '
360
377
  'use memgit search for anything task-specific.)')
361
378
  return '\n'.join(lines)
362
379
 
363
380
 
381
+ # ── hook handlers (invoked by AI-tool hosts, not humans) ─────────────────────
382
+
383
+ @cli.group(name='hook')
384
+ def hook():
385
+ """Hook handlers for AI-tool hosts (installed by `memgit setup hooks`)."""
386
+
387
+
388
+ @hook.command('prompt-recall')
389
+ def hook_prompt_recall():
390
+ """UserPromptSubmit: inject memories relevant to the prompt (stdin JSON)."""
391
+ from .hooks import prompt_recall
392
+ sys.exit(prompt_recall())
393
+
394
+
395
+ @hook.command('stop-guard')
396
+ def hook_stop_guard():
397
+ """Stop: nudge once if a substantive session saved nothing (stdin JSON)."""
398
+ from .hooks import stop_guard
399
+ sys.exit(stop_guard())
400
+
401
+
364
402
  # ── onboard ───────────────────────────────────────────────────────────────────
365
403
 
366
404
  ONBOARD_BRIEF = """\
@@ -369,18 +407,12 @@ ONBOARD_BRIEF = """\
369
407
  This project has {count} memories in memgit{count_note}. A memory store that
370
408
  starts empty mid-project is useless until it is seeded — do that now, once,
371
409
  and every future session (in any AI tool) starts oriented.
372
-
410
+ {digest_section}
373
411
  ## Instructions for the AI operator
374
412
 
375
413
  Extract 10–20 DURABLE facts about this project and save each one as a memory
376
414
  (via the memgit MCP `save_memory` tool, or `memgit add` in a shell).
377
- Read, in this order, whatever exists:
378
-
379
- 1. README / CLAUDE.md / CONTRIBUTING / docs/ — purpose, architecture, setup
380
- 2. Package manifests (package.json, pyproject.toml, go.mod, …) — stack, entry points, scripts
381
- 3. `git log --oneline -30` and recent PRs — what is being worked on RIGHT NOW
382
- 4. Config/env samples, CI files — deploy targets, environments, gates
383
- 5. The code layout itself — modules, boundaries, naming conventions
415
+ {reading_plan}
384
416
 
385
417
  ## What to save (one memory each, not a dump)
386
418
 
@@ -403,28 +435,71 @@ Checkpoint the seed set so it is versioned from day one:
403
435
  Then verify: `memgit search "<something about this project>"` should hit.
404
436
  """
405
437
 
438
+ _READING_PLAN_WITH_DIGEST = """\
439
+ The repo digest above was extracted deterministically from git and the
440
+ filesystem — treat it as ground truth and do NOT re-derive it. On a large
441
+ repo, do NOT crawl the tree. Work only from:
442
+
443
+ 1. The "Read these first" files listed in the digest — purpose, architecture, setup
444
+ 2. The manifests listed — stack, entry points, scripts, dependencies
445
+ 3. The recent commit subjects + hot areas — what is being worked on RIGHT NOW
446
+ (turn these into the "current state / active work" memory)
447
+ 4. Config/env samples and CI files if present — deploy targets, environments, gates"""
448
+
449
+ _READING_PLAN_GENERIC = """\
450
+ Read, in this order, whatever exists:
451
+
452
+ 1. README / CLAUDE.md / CONTRIBUTING / docs/ — purpose, architecture, setup
453
+ 2. Package manifests (package.json, pyproject.toml, go.mod, …) — stack, entry points, scripts
454
+ 3. `git log --oneline -30` and recent PRs — what is being worked on RIGHT NOW
455
+ 4. Config/env samples, CI files — deploy targets, environments, gates
456
+ 5. The code layout itself — modules, boundaries, naming conventions"""
457
+
406
458
 
407
459
  @cli.command()
408
460
  @click.option('--project', '-P', default=None,
409
461
  help='Project label (default: derived from the current directory)')
410
462
  @click.option('--path', 'proj_path', default='.', type=click.Path(exists=True),
411
463
  help='Project directory to onboard (default: cwd)')
412
- def onboard(project, proj_path):
464
+ @click.option('--json', 'fmt_json', is_flag=True, help='Emit the raw repo digest as JSON')
465
+ def onboard(project, proj_path, fmt_json):
413
466
  """Print the bootstrap brief for adopting memgit on an existing project.
414
467
 
415
468
  memgit only knows what has been saved — a project adopted midway starts
416
- with zero context. This prints a step-by-step brief for an AI agent (or
417
- you) to seed the store from the codebase: README, docs, git history,
418
- conventions. Paste it into your AI session, or run
469
+ with zero context. This mines the repo's git history and filesystem
470
+ (bounded and read-only, fast even on huge repos) into a factual digest,
471
+ then prints a step-by-step brief for an AI agent (or you) to seed the
472
+ store from it. Paste it into your AI session, or run
419
473
  `memgit onboard | pbcopy`.
420
474
  """
475
+ from .gitdigest import build_digest, format_digest
421
476
  from .importer import project_label_from_path
422
477
 
423
478
  repo = _require_repo()
424
- label = project or project_label_from_path(Path(proj_path)) or Path(proj_path).resolve().name
479
+ target = Path(proj_path)
480
+ label = project or project_label_from_path(target) or target.resolve().name
481
+
482
+ digest = build_digest(target)
483
+ if fmt_json:
484
+ import json as _j
485
+ digest['project'] = label
486
+ print(_j.dumps(digest, indent=2))
487
+ return
488
+
425
489
  count = sum(1 for m in repo.list() if m.project == label)
426
490
  count_note = '' if count else ' — it is a blank slate for this project'
427
- print(ONBOARD_BRIEF.format(project=label, count=count, count_note=count_note))
491
+
492
+ rendered = format_digest(digest)
493
+ if rendered:
494
+ digest_section = ('\n## Repo digest (auto-extracted — trust it, don\'t re-derive)\n\n'
495
+ f'{rendered}\n')
496
+ reading_plan = _READING_PLAN_WITH_DIGEST
497
+ else:
498
+ digest_section = ''
499
+ reading_plan = _READING_PLAN_GENERIC
500
+
501
+ print(ONBOARD_BRIEF.format(project=label, count=count, count_note=count_note,
502
+ digest_section=digest_section, reading_plan=reading_plan))
428
503
 
429
504
 
430
505
  # ── log ───────────────────────────────────────────────────────────────────────
@@ -565,35 +640,40 @@ def show(slug, toon, fmt_markdown):
565
640
  elif toon:
566
641
  print(serialize_mnemonic(m))
567
642
  else:
643
+ # User content and shas go through markup escaping — `[pj]`,
644
+ # `[[wikilinks]]`, or a sha like [fadc1234] would otherwise be
645
+ # eaten as rich tags and silently altered on screen.
646
+ from rich.markup import escape as _mesc
568
647
  sha_s = m.sha[:8] if m.sha else '?'
569
648
  p_label = {1: 'low', 2: 'medium', 3: '[bold red]CRITICAL[/bold red]'}[m.priority]
570
- proj = f' project={m.project}' if m.project else ''
571
- console.print(f'[bold cyan]{m.slug}[/bold cyan] [{m.type_code}] priority={p_label}{proj} sha={sha_s}')
649
+ proj = f' project={_mesc(m.project)}' if m.project else ''
650
+ console.print(f'[bold cyan]{_mesc(m.slug)}[/bold cyan] {_mesc("[" + m.type_code + "]")} '
651
+ f'priority={p_label}{proj} sha={_mesc(sha_s)}')
572
652
  console.print(f'')
573
- console.print(f'[bold]RULE[/bold] {m.rule}')
653
+ console.print(f'[bold]RULE[/bold] {_mesc(m.rule)}')
574
654
  if m.why:
575
- console.print(f'[bold]WHY[/bold] {m.why}')
655
+ console.print(f'[bold]WHY[/bold] {_mesc(m.why)}')
576
656
  if m.when:
577
- console.print(f'[bold]WHEN[/bold] {m.when}')
657
+ console.print(f'[bold]WHEN[/bold] {_mesc(m.when)}')
578
658
  if m.desc:
579
- console.print(f'[bold]DESC[/bold] {m.desc}')
659
+ console.print(f'[bold]DESC[/bold] {_mesc(m.desc)}')
580
660
  if m.body:
581
661
  console.print(f'\n[bold]BODY[/bold]')
582
- console.print(m.body)
662
+ console.print(m.body, markup=False)
583
663
  if m.who:
584
- console.print(f'[bold]WHO[/bold] {m.who}')
664
+ console.print(f'[bold]WHO[/bold] {_mesc(m.who)}')
585
665
  if m.where:
586
- console.print(f'[bold]WHERE[/bold] {m.where}')
666
+ console.print(f'[bold]WHERE[/bold] {_mesc(m.where)}')
587
667
  if m.inc:
588
- console.print(f'[bold]INC[/bold] {m.inc}')
668
+ console.print(f'[bold]INC[/bold] {_mesc(m.inc)}')
589
669
  if m.cost:
590
- console.print(f'[bold]COST[/bold] {m.cost}')
670
+ console.print(f'[bold]COST[/bold] {_mesc(m.cost)}')
591
671
  if m.tags:
592
- console.print(f'[dim]Tags: {", ".join(m.tags)}[/dim]')
672
+ console.print(f'[dim]Tags: {_mesc(", ".join(m.tags))}[/dim]')
593
673
  if m.related:
594
- console.print(f'[dim]Related: {", ".join(m.related)}[/dim]')
674
+ console.print(f'[dim]Related: {_mesc(", ".join(m.related))}[/dim]')
595
675
  if m.supersedes:
596
- console.print(f'[dim]Supersedes: {", ".join(m.supersedes)}[/dim]')
676
+ console.print(f'[dim]Supersedes: {_mesc(", ".join(m.supersedes))}[/dim]')
597
677
 
598
678
 
599
679
  # ── list ──────────────────────────────────────────────────────────────────────
@@ -827,6 +907,7 @@ def lint():
827
907
  console.print(f'[green]OK[/green] — {len(mnemonics)} mnemonics, no issues')
828
908
  else:
829
909
  console.print(f'[yellow]{issues} issue{"s" if issues != 1 else ""}[/yellow]')
910
+ sys.exit(1) # let scripts/CI gate on lint
830
911
 
831
912
 
832
913
  import re # noqa: E402 — needed for lint command
@@ -965,6 +1046,15 @@ def sync(message, dry_run):
965
1046
  mnemonics = from_claude_code()
966
1047
 
967
1048
  if not mnemonics:
1049
+ # No markdown sources on this machine — but anything already staged
1050
+ # (MCP saves, CLI adds) must still be checkpointed, or it lingers
1051
+ # uncommitted forever on stores fed purely through MCP.
1052
+ if not dry_run:
1053
+ msg = message or _staged_diff_message(repo)
1054
+ sha = repo.commit(message=msg, trigger='session_end') if msg else None
1055
+ if sha:
1056
+ console.print(f'[green]sync[/green] {sha[:8]} {msg}')
1057
+ return
968
1058
  console.print('[dim]No Claude Code memories found.[/dim]')
969
1059
  return
970
1060
 
@@ -1889,15 +1979,41 @@ def setup_gemini_cli(dry_run):
1889
1979
  _run_target('Gemini CLI', path, _patch_mcp_servers, dry_run)
1890
1980
 
1891
1981
 
1982
+ #: substrings identifying a hook command as one of ours (any generation)
1983
+ _MEMGIT_HOOK_SIGNS = ('resume --plain', 'hook prompt-recall', 'hook stop-guard', ' sync')
1984
+
1985
+
1986
+ def _is_memgit_hook_entry(h: dict) -> bool:
1987
+ return any(
1988
+ 'memgit' in inner.get('command', '') and
1989
+ any(sign in inner.get('command', '') for sign in _MEMGIT_HOOK_SIGNS)
1990
+ for inner in h.get('hooks', []) if isinstance(inner, dict)
1991
+ )
1992
+
1993
+
1892
1994
  @setup.command('hooks')
1893
- @click.option('--remove', is_flag=True, help='Uninstall the memgit SessionStart hook')
1995
+ @click.option('--remove', is_flag=True, help='Uninstall all memgit hooks')
1996
+ @click.option('--no-recall', is_flag=True,
1997
+ help='Skip the per-prompt auto-recall hook (UserPromptSubmit)')
1998
+ @click.option('--no-guard', is_flag=True,
1999
+ help='Skip the end-of-session capture guard (Stop)')
1894
2000
  @click.option('--dry-run', is_flag=True, help='Show the change without writing')
1895
- def setup_hooks(remove, dry_run):
1896
- """Install a Claude Code SessionStart hook that injects `memgit resume`.
2001
+ def setup_hooks(remove, no_recall, no_guard, dry_run):
2002
+ """Install the Claude Code hooks that make memory automatic.
2003
+
2004
+ Four hooks, one principle: what a hook enforces happens, what a tool
2005
+ description suggests mostly doesn't (measured: 6% voluntary engagement
2006
+ vs 100% hook delivery).
1897
2007
 
1898
- After this, every new Claude Code session (including /clear and resume)
1899
- automatically starts with your last checkpoints, work in flight, and
1900
- critical rules in context — the model doesn't have to remember to look.
2008
+ \b
2009
+ SessionStart inject `memgit resume` last checkpoints, work in
2010
+ flight, critical rules
2011
+ UserPromptSubmit inject memories relevant to each prompt (BM25,
2012
+ silent when nothing clears the relevance bar)
2013
+ Stop capture guard — a substantive session ending with
2014
+ zero memory writes gets ONE nudge to save durable
2015
+ facts; plus async `memgit sync` to checkpoint
2016
+ markdown memories
1901
2017
 
1902
2018
  Hooks live in ~/.claude/settings.json (unlike MCP servers, which live
1903
2019
  in ~/.claude.json).
@@ -1905,8 +2021,10 @@ def setup_hooks(remove, dry_run):
1905
2021
  import shlex
1906
2022
  settings_path = Path.home() / '.claude' / 'settings.json'
1907
2023
  base = ' '.join(shlex.quote(p) for p in _memgit_base_cmd())
1908
- # `|| true`: a broken store must never block session start
1909
- hook_command = f'{base} resume --plain 2>/dev/null || true'
2024
+
2025
+ from .repo import default_store_candidates
2026
+ store = next((c for c in default_store_candidates() if (c / '.memgit').is_dir()),
2027
+ Path.home() / '.claude' / 'memgit-store')
1910
2028
 
1911
2029
  if settings_path.exists():
1912
2030
  try:
@@ -1918,43 +2036,55 @@ def setup_hooks(remove, dry_run):
1918
2036
  data = {}
1919
2037
 
1920
2038
  hooks = data.setdefault('hooks', {})
1921
- session_start = hooks.setdefault('SessionStart', [])
1922
2039
 
1923
- def _is_memgit_hook(h: dict) -> bool:
1924
- return any('memgit resume' in inner.get('command', '')
1925
- for inner in h.get('hooks', []) if isinstance(inner, dict))
2040
+ # `|| true` + stderr silenced everywhere: a broken store must never
2041
+ # block the user's session.
2042
+ plan: dict[str, list[dict]] = {
2043
+ 'SessionStart': [
2044
+ {'type': 'command',
2045
+ 'command': f'{base} resume --plain 2>/dev/null || true'},
2046
+ ],
2047
+ 'UserPromptSubmit': [] if no_recall else [
2048
+ {'type': 'command',
2049
+ 'command': f'{base} hook prompt-recall 2>/dev/null || true'},
2050
+ ],
2051
+ 'Stop': ([] if no_guard else [
2052
+ {'type': 'command',
2053
+ 'command': f'{base} hook stop-guard 2>/dev/null || true'},
2054
+ ]) + [
2055
+ {'type': 'command',
2056
+ 'command': f'cd {shlex.quote(str(store))} && {base} sync 2>/dev/null || true',
2057
+ 'async': True},
2058
+ ],
2059
+ }
2060
+
2061
+ changed = []
2062
+ for event in ('SessionStart', 'UserPromptSubmit', 'Stop'):
2063
+ entries = hooks.setdefault(event, [])
2064
+ had = [h for h in entries if isinstance(h, dict) and _is_memgit_hook_entry(h)]
2065
+ entries[:] = [h for h in entries if h not in had]
2066
+ if not remove and plan[event]:
2067
+ entries.append({'hooks': plan[event]})
2068
+ changed.append(event)
2069
+ if not entries:
2070
+ hooks.pop(event, None)
2071
+ if not hooks:
2072
+ data.pop('hooks', None)
1926
2073
 
1927
- existing = [h for h in session_start if isinstance(h, dict) and _is_memgit_hook(h)]
2074
+ if not dry_run:
2075
+ _write_json_safe(settings_path, data)
1928
2076
 
2077
+ suffix = ' [dim](dry run)[/dim]' if dry_run else ''
1929
2078
  if remove:
1930
- if not existing:
1931
- console.print('[dim]No memgit SessionStart hook installed.[/dim]')
1932
- return
1933
- session_start[:] = [h for h in session_start if h not in existing]
1934
- if not session_start:
1935
- hooks.pop('SessionStart', None)
1936
- if not hooks:
1937
- data.pop('hooks', None)
1938
- if not dry_run:
1939
- _write_json_safe(settings_path, data)
1940
- console.print(f'[yellow]removed[/yellow] memgit SessionStart hook from {settings_path}'
1941
- + (' [dim](dry run)[/dim]' if dry_run else ''))
2079
+ console.print(f'[yellow]removed[/yellow] all memgit hooks from {settings_path}{suffix}')
1942
2080
  return
1943
-
1944
- entry = {'hooks': [{'type': 'command', 'command': hook_command}]}
1945
- if existing:
1946
- if existing[0] == entry and len(existing) == 1:
1947
- console.print(f'[green][/green] already installed in {settings_path}')
1948
- return
1949
- session_start[:] = [h for h in session_start if h not in existing]
1950
- session_start.append(entry)
1951
-
1952
- if not dry_run:
1953
- _write_json_safe(settings_path, data)
1954
- console.print(f'[green]✓[/green] SessionStart hook installed in {settings_path}'
1955
- + (' [dim](dry run)[/dim]' if dry_run else ''))
1956
- console.print(f' [dim]{hook_command}[/dim]')
1957
- console.print('[dim]Every new Claude Code session now starts with your memgit resume digest.[/dim]')
2081
+ console.print(f'[green]✓[/green] memgit hooks installed in {settings_path}{suffix}')
2082
+ for event in changed:
2083
+ for inner in plan[event]:
2084
+ tag = ' [dim](async)[/dim]' if inner.get('async') else ''
2085
+ console.print(f' [cyan]{event}[/cyan] [dim]{inner["command"]}[/dim]{tag}')
2086
+ console.print('[dim]Resume at session start, relevant memories per prompt, '
2087
+ 'capture guard + sync at stop.[/dim]')
1958
2088
 
1959
2089
 
1960
2090
  @setup.command('print-config')