memgit 0.13.0__tar.gz → 0.14.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 (83) hide show
  1. {memgit-0.13.0 → memgit-0.14.0}/PKG-INFO +36 -7
  2. {memgit-0.13.0 → memgit-0.14.0}/README.md +33 -4
  3. {memgit-0.13.0 → memgit-0.14.0}/memgit/__init__.py +1 -1
  4. {memgit-0.13.0 → memgit-0.14.0}/memgit/cli.py +222 -38
  5. memgit-0.14.0/memgit/cloud/__init__.py +7 -0
  6. memgit-0.14.0/memgit/cloud/auto.py +278 -0
  7. {memgit-0.13.0 → memgit-0.14.0}/memgit/cloud/commands.py +131 -4
  8. {memgit-0.13.0 → memgit-0.14.0}/memgit/cloud/sync.py +17 -0
  9. memgit-0.14.0/memgit/drift.py +191 -0
  10. {memgit-0.13.0 → memgit-0.14.0}/memgit/env.py +13 -0
  11. memgit-0.14.0/memgit/gate.py +431 -0
  12. {memgit-0.13.0 → memgit-0.14.0}/memgit/hooks.py +161 -7
  13. {memgit-0.13.0 → memgit-0.14.0}/memgit/links.py +76 -0
  14. {memgit-0.13.0 → memgit-0.14.0}/memgit/markup.py +2 -1
  15. {memgit-0.13.0 → memgit-0.14.0}/memgit/mcp_server.py +108 -2
  16. {memgit-0.13.0 → memgit-0.14.0}/memgit/metrics.py +22 -1
  17. {memgit-0.13.0 → memgit-0.14.0}/memgit/models.py +5 -0
  18. {memgit-0.13.0 → memgit-0.14.0}/memgit/repo.py +57 -10
  19. {memgit-0.13.0 → memgit-0.14.0}/memgit/scorer.py +19 -0
  20. {memgit-0.13.0 → memgit-0.14.0}/memgit/toon.py +29 -0
  21. {memgit-0.13.0 → memgit-0.14.0}/memgit.egg-info/PKG-INFO +36 -7
  22. {memgit-0.13.0 → memgit-0.14.0}/memgit.egg-info/SOURCES.txt +7 -0
  23. {memgit-0.13.0 → memgit-0.14.0}/pyproject.toml +9 -2
  24. memgit-0.14.0/tests/test_action_gate.py +424 -0
  25. memgit-0.14.0/tests/test_cloud_autosync.py +248 -0
  26. memgit-0.14.0/tests/test_cloud_two_machines.py +136 -0
  27. memgit-0.14.0/tests/test_mcp_store_bootstrap.py +69 -0
  28. {memgit-0.13.0 → memgit-0.14.0}/tests/test_v030.py +3 -0
  29. {memgit-0.13.0 → memgit-0.14.0}/tests/test_version_stamps.py +16 -4
  30. memgit-0.13.0/memgit/cloud/__init__.py +0 -6
  31. {memgit-0.13.0 → memgit-0.14.0}/LICENSE +0 -0
  32. {memgit-0.13.0 → memgit-0.14.0}/memgit/audit.py +0 -0
  33. {memgit-0.13.0 → memgit-0.14.0}/memgit/backup.py +0 -0
  34. {memgit-0.13.0 → memgit-0.14.0}/memgit/cloud/client.py +0 -0
  35. {memgit-0.13.0 → memgit-0.14.0}/memgit/cloud/crypto.py +0 -0
  36. {memgit-0.13.0 → memgit-0.14.0}/memgit/cloud/state.py +0 -0
  37. {memgit-0.13.0 → memgit-0.14.0}/memgit/delivery.py +0 -0
  38. {memgit-0.13.0 → memgit-0.14.0}/memgit/evaluate.py +0 -0
  39. {memgit-0.13.0 → memgit-0.14.0}/memgit/gitdigest.py +0 -0
  40. {memgit-0.13.0 → memgit-0.14.0}/memgit/graph.py +0 -0
  41. {memgit-0.13.0 → memgit-0.14.0}/memgit/http_server.py +0 -0
  42. {memgit-0.13.0 → memgit-0.14.0}/memgit/importer.py +0 -0
  43. {memgit-0.13.0 → memgit-0.14.0}/memgit/license.py +0 -0
  44. {memgit-0.13.0 → memgit-0.14.0}/memgit/org.py +0 -0
  45. {memgit-0.13.0 → memgit-0.14.0}/memgit/project.py +0 -0
  46. {memgit-0.13.0 → memgit-0.14.0}/memgit/sanitize.py +0 -0
  47. {memgit-0.13.0 → memgit-0.14.0}/memgit/savings.py +0 -0
  48. {memgit-0.13.0 → memgit-0.14.0}/memgit/store.py +0 -0
  49. {memgit-0.13.0 → memgit-0.14.0}/memgit/tiers.py +0 -0
  50. {memgit-0.13.0 → memgit-0.14.0}/memgit/tokens.py +0 -0
  51. {memgit-0.13.0 → memgit-0.14.0}/memgit/usage.py +0 -0
  52. {memgit-0.13.0 → memgit-0.14.0}/memgit.egg-info/dependency_links.txt +0 -0
  53. {memgit-0.13.0 → memgit-0.14.0}/memgit.egg-info/entry_points.txt +0 -0
  54. {memgit-0.13.0 → memgit-0.14.0}/memgit.egg-info/requires.txt +2 -2
  55. {memgit-0.13.0 → memgit-0.14.0}/memgit.egg-info/top_level.txt +0 -0
  56. {memgit-0.13.0 → memgit-0.14.0}/setup.cfg +0 -0
  57. {memgit-0.13.0 → memgit-0.14.0}/tests/test_accrue.py +0 -0
  58. {memgit-0.13.0 → memgit-0.14.0}/tests/test_advanced.py +0 -0
  59. {memgit-0.13.0 → memgit-0.14.0}/tests/test_aliases.py +0 -0
  60. {memgit-0.13.0 → memgit-0.14.0}/tests/test_audit.py +0 -0
  61. {memgit-0.13.0 → memgit-0.14.0}/tests/test_backup.py +0 -0
  62. {memgit-0.13.0 → memgit-0.14.0}/tests/test_cloud_guard.py +0 -0
  63. {memgit-0.13.0 → memgit-0.14.0}/tests/test_core.py +0 -0
  64. {memgit-0.13.0 → memgit-0.14.0}/tests/test_delivery.py +0 -0
  65. {memgit-0.13.0 → memgit-0.14.0}/tests/test_dependency_floor.py +0 -0
  66. {memgit-0.13.0 → memgit-0.14.0}/tests/test_env_inventory.py +0 -0
  67. {memgit-0.13.0 → memgit-0.14.0}/tests/test_git_args.py +0 -0
  68. {memgit-0.13.0 → memgit-0.14.0}/tests/test_license.py +0 -0
  69. {memgit-0.13.0 → memgit-0.14.0}/tests/test_lock_exclusion.py +0 -0
  70. {memgit-0.13.0 → memgit-0.14.0}/tests/test_markup.py +0 -0
  71. {memgit-0.13.0 → memgit-0.14.0}/tests/test_org.py +0 -0
  72. {memgit-0.13.0 → memgit-0.14.0}/tests/test_savings.py +0 -0
  73. {memgit-0.13.0 → memgit-0.14.0}/tests/test_server_lifecycle.py +0 -0
  74. {memgit-0.13.0 → memgit-0.14.0}/tests/test_setup.py +0 -0
  75. {memgit-0.13.0 → memgit-0.14.0}/tests/test_store_repo.py +0 -0
  76. {memgit-0.13.0 → memgit-0.14.0}/tests/test_tiers.py +0 -0
  77. {memgit-0.13.0 → memgit-0.14.0}/tests/test_toon.py +0 -0
  78. {memgit-0.13.0 → memgit-0.14.0}/tests/test_v020.py +0 -0
  79. {memgit-0.13.0 → memgit-0.14.0}/tests/test_v040.py +0 -0
  80. {memgit-0.13.0 → memgit-0.14.0}/tests/test_v060.py +0 -0
  81. {memgit-0.13.0 → memgit-0.14.0}/tests/test_v070.py +0 -0
  82. {memgit-0.13.0 → memgit-0.14.0}/tests/test_v080.py +0 -0
  83. {memgit-0.13.0 → memgit-0.14.0}/tests/test_v081.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.13.0
3
+ Version: 0.14.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
@@ -22,14 +22,14 @@ License-File: LICENSE
22
22
  Requires-Dist: click>=8.0
23
23
  Requires-Dist: rich>=13.0
24
24
  Requires-Dist: mcp<2,>=1.28.1
25
+ Requires-Dist: pynacl>=1.5
26
+ Requires-Dist: httpx>=0.27
25
27
  Provides-Extra: dev
26
28
  Requires-Dist: pytest>=8.0; extra == "dev"
27
29
  Requires-Dist: pytest-anyio>=0.0.0; extra == "dev"
28
30
  Provides-Extra: tokens
29
31
  Requires-Dist: tiktoken>=0.7; extra == "tokens"
30
32
  Provides-Extra: cloud
31
- Requires-Dist: pynacl>=1.5; extra == "cloud"
32
- Requires-Dist: httpx>=0.27; extra == "cloud"
33
33
  Dynamic: license-file
34
34
 
35
35
  <p align="center">
@@ -160,7 +160,7 @@ pip install memgit
160
160
  ```
161
161
  (The Chocolatey package is live on community.chocolatey.org; newly pushed versions can take a few days to clear moderation — `pip install memgit` always has the latest.)
162
162
 
163
- **Any AI tool config (no Python needed — npx auto-installs on first run):**
163
+ **Any AI tool config (needs Python 3.11+ on PATH; npx installs memgit into its own environment on first run and creates the store):**
164
164
  ```json
165
165
  { "mcpServers": { "memgit": { "command": "npx", "args": ["-y", "memgit-mcp"] } } }
166
166
  ```
@@ -216,15 +216,16 @@ memgit resume --json # for tooling
216
216
  Wire it into Claude Code so memory becomes **automatic** — no tool call, no judgment required:
217
217
 
218
218
  ```bash
219
- memgit setup hooks # installs all five hooks (~/.claude/settings.json)
219
+ memgit setup hooks # installs all six hooks (~/.claude/settings.json)
220
220
  ```
221
221
 
222
222
  | Hook | What it enforces |
223
223
  |---|---|
224
224
  | `SessionStart` | every session opens with the resume digest in context — status board, checkpoints, critical rules, memory index |
225
225
  | `UserPromptSubmit` | each prompt is BM25-matched against the store; relevant memories are injected, ending with a "+N more on '<topic>'" depth hint when more exists (silent when nothing clears the relevance bar; never repeats within a session) — `--no-recall` to skip |
226
+ | `PreToolUse` | a Bash or file-write call that matches a memory's triggers is refused with the rule as the reason (priority-3 feedback and conventions) or gets the rule as context (the rest). memgit allow SLUG opens a short override window. Trigger cache only, `--no-gate` to skip |
226
227
  | `PostToolUse` | reading a file whose path matches a memory tag surfaces a one-line hint ("6 memories tagged 'x' relate to this path") — tagmap cache only, capped 3/session, `--no-ctx-recall` to skip |
227
- | `Stop` (guard) | a session that did real work but saved nothing gets ONE nudge to save durable facts before finishing — `--no-guard` to skip |
228
+ | `Stop` (guard) | a governed call that went through without the gate, or a session that did real work but saved nothing, gets ONE block before finishing. `--no-guard` to skip |
228
229
  | `Stop` (sync) | markdown memories are checkpointed asynchronously at session end |
229
230
 
230
231
  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.
@@ -325,6 +326,29 @@ memgit pro deactivate # remove the key from this machine
325
326
  exports keep working.
326
327
  - If this store is logged in to memgit cloud, `pro activate` also upgrades that account.
327
328
 
329
+ ### Sync between machines and with a team
330
+
331
+ ```bash
332
+ memgit cloud signup --email you@example.com # 14-day trial; keys are made on this machine
333
+ memgit cloud team create myteam
334
+ memgit cloud link myteam/memory # creates the cloud repo on first link
335
+ memgit cloud auto on # every save syncs in the background from now on
336
+ ```
337
+
338
+ With autosync on, each save (including every `save_memory` your AI makes) starts one
339
+ background pull-then-push and returns immediately, and each session start pulls what other
340
+ machines saved. `memgit cloud auto status` shows the last run and why it would not run.
341
+
342
+ On a machine that runs an agent but no interactive sessions (a Mac mini, a VM), log in and
343
+ link once, then keep it current under systemd or launchd:
344
+
345
+ ```bash
346
+ memgit cloud watch --interval 60 # pull and push forever, one JSON line per run
347
+ memgit cloud watch --export ~/agent/.hermes.md # also write the digest for agents that read context files
348
+ ```
349
+
350
+ Everything leaves the machine end-to-end encrypted; the server stores ciphertext only.
351
+
328
352
  ---
329
353
 
330
354
  ## Backups that actually happen
@@ -445,7 +469,9 @@ memgit setup cline
445
469
  memgit setup continue
446
470
  memgit setup gemini-cli
447
471
  memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
448
- # capture guard + auto-sync at stop (--no-recall / --no-guard)
472
+ # action gate on Bash and file writes, compliance and
473
+ # capture guard + auto-sync at stop (--no-recall / --no-guard / --no-gate)
474
+ memgit doctor --rules # critical rules next to same-topic lines in CLAUDE.md, AGENTS.md, ...
449
475
 
450
476
  # Pro (licence key)
451
477
  memgit pro activate <key> # Polar-issued key; unlocks hosted E2E sync
@@ -510,6 +536,9 @@ memgit talks to nothing.
510
536
  | Cloud sync | `MEMGIT_CLOUD_API` | Overrides the memgit cloud API base URL. |
511
537
  | | `MEMGIT_CLOUD_APP` | Overrides the memgit cloud web app base URL used in printed links. |
512
538
  | | `MEMGIT_CLOUD_NO_CACHE` | Set to 1/true/yes to keep decrypted keys out of credentials.json; the passphrase is then re-prompted per command. |
539
+ | | `MEMGIT_NO_AUTOSYNC` | Set to 1/true/yes to stop saves and session starts from syncing to memgit cloud in the background, for this process only. |
540
+ | | `MEMGIT_AUTOSYNC_MAX_AGE` | Seconds after which a read (search, recall, an MCP lookup) starts a background fetch from memgit cloud (default 300). 0 turns read-triggered fetching off; saves and session starts still sync. |
541
+ | | `MEMGIT_AUTOSYNC_WAIT` | Seconds a session start waits for the fetch from memgit cloud before rendering the digest (default 3). 0 never waits. |
513
542
  | Tuning | `MEMGIT_LOCK_TIMEOUT` | Seconds a writer waits for the store lock before giving up (default 10). |
514
543
  | | `MEMGIT_IDLE_EVICT_SECONDS` | Idle seconds before the MCP server drops its in-memory caches while staying connected (default 900). |
515
544
  | | `MEMGIT_HOUSEKEEPING_INTERVAL` | Seconds between the MCP server's housekeeping passes. |
@@ -126,7 +126,7 @@ pip install memgit
126
126
  ```
127
127
  (The Chocolatey package is live on community.chocolatey.org; newly pushed versions can take a few days to clear moderation — `pip install memgit` always has the latest.)
128
128
 
129
- **Any AI tool config (no Python needed — npx auto-installs on first run):**
129
+ **Any AI tool config (needs Python 3.11+ on PATH; npx installs memgit into its own environment on first run and creates the store):**
130
130
  ```json
131
131
  { "mcpServers": { "memgit": { "command": "npx", "args": ["-y", "memgit-mcp"] } } }
132
132
  ```
@@ -182,15 +182,16 @@ memgit resume --json # for tooling
182
182
  Wire it into Claude Code so memory becomes **automatic** — no tool call, no judgment required:
183
183
 
184
184
  ```bash
185
- memgit setup hooks # installs all five hooks (~/.claude/settings.json)
185
+ memgit setup hooks # installs all six hooks (~/.claude/settings.json)
186
186
  ```
187
187
 
188
188
  | Hook | What it enforces |
189
189
  |---|---|
190
190
  | `SessionStart` | every session opens with the resume digest in context — status board, checkpoints, critical rules, memory index |
191
191
  | `UserPromptSubmit` | each prompt is BM25-matched against the store; relevant memories are injected, ending with a "+N more on '<topic>'" depth hint when more exists (silent when nothing clears the relevance bar; never repeats within a session) — `--no-recall` to skip |
192
+ | `PreToolUse` | a Bash or file-write call that matches a memory's triggers is refused with the rule as the reason (priority-3 feedback and conventions) or gets the rule as context (the rest). memgit allow SLUG opens a short override window. Trigger cache only, `--no-gate` to skip |
192
193
  | `PostToolUse` | reading a file whose path matches a memory tag surfaces a one-line hint ("6 memories tagged 'x' relate to this path") — tagmap cache only, capped 3/session, `--no-ctx-recall` to skip |
193
- | `Stop` (guard) | a session that did real work but saved nothing gets ONE nudge to save durable facts before finishing — `--no-guard` to skip |
194
+ | `Stop` (guard) | a governed call that went through without the gate, or a session that did real work but saved nothing, gets ONE block before finishing. `--no-guard` to skip |
194
195
  | `Stop` (sync) | markdown memories are checkpointed asynchronously at session end |
195
196
 
196
197
  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.
@@ -291,6 +292,29 @@ memgit pro deactivate # remove the key from this machine
291
292
  exports keep working.
292
293
  - If this store is logged in to memgit cloud, `pro activate` also upgrades that account.
293
294
 
295
+ ### Sync between machines and with a team
296
+
297
+ ```bash
298
+ memgit cloud signup --email you@example.com # 14-day trial; keys are made on this machine
299
+ memgit cloud team create myteam
300
+ memgit cloud link myteam/memory # creates the cloud repo on first link
301
+ memgit cloud auto on # every save syncs in the background from now on
302
+ ```
303
+
304
+ With autosync on, each save (including every `save_memory` your AI makes) starts one
305
+ background pull-then-push and returns immediately, and each session start pulls what other
306
+ machines saved. `memgit cloud auto status` shows the last run and why it would not run.
307
+
308
+ On a machine that runs an agent but no interactive sessions (a Mac mini, a VM), log in and
309
+ link once, then keep it current under systemd or launchd:
310
+
311
+ ```bash
312
+ memgit cloud watch --interval 60 # pull and push forever, one JSON line per run
313
+ memgit cloud watch --export ~/agent/.hermes.md # also write the digest for agents that read context files
314
+ ```
315
+
316
+ Everything leaves the machine end-to-end encrypted; the server stores ciphertext only.
317
+
294
318
  ---
295
319
 
296
320
  ## Backups that actually happen
@@ -411,7 +435,9 @@ memgit setup cline
411
435
  memgit setup continue
412
436
  memgit setup gemini-cli
413
437
  memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
414
- # capture guard + auto-sync at stop (--no-recall / --no-guard)
438
+ # action gate on Bash and file writes, compliance and
439
+ # capture guard + auto-sync at stop (--no-recall / --no-guard / --no-gate)
440
+ memgit doctor --rules # critical rules next to same-topic lines in CLAUDE.md, AGENTS.md, ...
415
441
 
416
442
  # Pro (licence key)
417
443
  memgit pro activate <key> # Polar-issued key; unlocks hosted E2E sync
@@ -476,6 +502,9 @@ memgit talks to nothing.
476
502
  | Cloud sync | `MEMGIT_CLOUD_API` | Overrides the memgit cloud API base URL. |
477
503
  | | `MEMGIT_CLOUD_APP` | Overrides the memgit cloud web app base URL used in printed links. |
478
504
  | | `MEMGIT_CLOUD_NO_CACHE` | Set to 1/true/yes to keep decrypted keys out of credentials.json; the passphrase is then re-prompted per command. |
505
+ | | `MEMGIT_NO_AUTOSYNC` | Set to 1/true/yes to stop saves and session starts from syncing to memgit cloud in the background, for this process only. |
506
+ | | `MEMGIT_AUTOSYNC_MAX_AGE` | Seconds after which a read (search, recall, an MCP lookup) starts a background fetch from memgit cloud (default 300). 0 turns read-triggered fetching off; saves and session starts still sync. |
507
+ | | `MEMGIT_AUTOSYNC_WAIT` | Seconds a session start waits for the fetch from memgit cloud before rendering the digest (default 3). 0 never waits. |
479
508
  | Tuning | `MEMGIT_LOCK_TIMEOUT` | Seconds a writer waits for the store lock before giving up (default 10). |
480
509
  | | `MEMGIT_IDLE_EVICT_SECONDS` | Idle seconds before the MCP server drops its in-memory caches while staying connected (default 900). |
481
510
  | | `MEMGIT_HOUSEKEEPING_INTERVAL` | Seconds between the MCP server's housekeeping passes. |
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.13.0"
3
+ __version__ = "0.14.0"
@@ -657,6 +657,15 @@ def resume(checkpoints, recent, plain, fmt_json, project):
657
657
  import json as _j
658
658
  from .project import detect_project
659
659
  repo = _require_repo()
660
+ # Session start is when another machine's memories should arrive. The pull
661
+ # runs detached and the digest waits a bounded moment for it (3 s unless
662
+ # MEMGIT_AUTOSYNC_WAIT says otherwise), so a quick network puts them in
663
+ # this digest and a slow one never holds it up.
664
+ try:
665
+ from .cloud.auto import fetch_for_session
666
+ fetch_for_session(repo.path)
667
+ except Exception:
668
+ pass
660
669
  if project is None:
661
670
  project = detect_project()
662
671
  ctx = repo.resume_context(checkpoints=checkpoints, recent=recent, project=project)
@@ -743,9 +752,15 @@ def resume(checkpoints, recent, plain, fmt_json, project):
743
752
  console.print()
744
753
 
745
754
 
746
- #: Hard character budget for the injected resume digest (~2.4k tokens). Paid
747
- #: at the start of EVERY session — past this, sections trim in a fixed order.
748
- RESUME_BUDGET_CHARS = 9_500
755
+ #: Size at which Claude Code stops inlining hook output: above it the digest
756
+ #: is saved to a file and the model sees a ~2 KB preview, so everything past
757
+ #: the preview (status board, critical rules, index) never reaches context.
758
+ #: Measured 2026-10-23: an 11,297-character digest was persisted, not inlined.
759
+ HOST_INLINE_LIMIT_CHARS = 10_000
760
+ #: Hard budget for the injected resume digest, with headroom under the host
761
+ #: limit. Measured in UTF-16 code units, the way a JavaScript host counts
762
+ #: string length (an emoji is two). Paid at the start of EVERY session.
763
+ RESUME_BUDGET_CHARS = 9_000
749
764
 
750
765
  #: A status-board tracker not re-saved in this many days is flagged as possibly
751
766
  #: stale — trackers are only as fresh as their last write, so an old one may
@@ -759,33 +774,82 @@ def _tracker_age_days(ts) -> int:
759
774
  return max(0, (now - ts).days)
760
775
 
761
776
 
762
- def _format_resume_plain(ctx: dict) -> str:
777
+ def _host_len(text: str) -> int:
778
+ """Length as a JavaScript host counts it (UTF-16 code units)."""
779
+ return len(text.encode('utf-16-le')) // 2
780
+
781
+
782
+ def _strip_auto_block(body: str) -> str:
783
+ """A core-guide body without its usage-ranked pointer block."""
784
+ from .delivery import AUTO_START, AUTO_END
785
+ if AUTO_START not in body:
786
+ return body
787
+ head, _, rest = body.partition(AUTO_START)
788
+ _, _, tail = rest.partition(AUTO_END)
789
+ return (head.rstrip() + '\n(auto pointers cut to fit the session-start '
790
+ 'budget — memgit core show has them)\n' + tail.lstrip()).rstrip()
791
+
792
+
793
+ def _format_resume_plain(ctx: dict, budget: int = None) -> str:
763
794
  """Plain-text resume digest — injected into AI context by the SessionStart
764
- hook, under a hard budget of RESUME_BUDGET_CHARS.
765
-
766
- When the render exceeds the budget, sections are trimmed in a fixed
767
- order — recent memories 10→5, checkpoints 5→3, critical-rule text
768
- clipped to 160 chars, memory-index topics 8→5, recent 5→3 — re-rendering
769
- after each cut until it fits. The core operating guide body and the
770
- status board are NEVER trimmed: they are the two surfaces the model must
771
- be able to trust in full.
795
+ hook, never longer than RESUME_BUDGET_CHARS.
796
+
797
+ The budget is a guarantee, not a target: a digest over the host's inline
798
+ limit is saved to a file and almost none of it is read, which is worse
799
+ than any trim. Sections trim in a fixed order, re-rendering after each
800
+ cut: recent memories, checkpoints, index and rule text first; then the
801
+ status board and critical list shrink; then the core guide loses its
802
+ auto-pointer block; and only as a last resort is the core guide body cut,
803
+ with a pointer to the full text.
772
804
  """
805
+ budget = budget or RESUME_BUDGET_CHARS
773
806
  text = _render_resume_plain(ctx)
774
- if len(text) <= RESUME_BUDGET_CHARS:
807
+ if _host_len(text) <= budget:
775
808
  return text
776
809
  ctx = dict(ctx) # never mutate the caller's context
810
+
811
+ def _core_no_auto(c):
812
+ c['core_memories'] = [dict(m, body=_strip_auto_block(m.get('body') or ''))
813
+ for m in c.get('core_memories') or []]
814
+
777
815
  trims = (
778
816
  lambda c: c.update(recent_memories=c.get('recent_memories', [])[:5]),
779
817
  lambda c: c.update(checkpoints=c.get('checkpoints', [])[:3]),
780
818
  lambda c: c.update(_crit_clip=160),
781
819
  lambda c: c.update(entity_index=c.get('entity_index', [])[:5]),
782
820
  lambda c: c.update(recent_memories=c.get('recent_memories', [])[:3]),
821
+ lambda c: c.update(_tracker_clip=100, _crit_clip=130),
822
+ lambda c: c.update(_crit_max=12),
823
+ _core_no_auto,
824
+ lambda c: c.update(tracker_memories=c.get('tracker_memories', [])[:5],
825
+ recent_memories=[], checkpoints=c.get('checkpoints', [])[:1]),
826
+ lambda c: c.update(_crit_max=8, _crit_clip=110, entity_index=[]),
783
827
  )
784
828
  for trim in trims:
785
829
  trim(ctx)
786
830
  text = _render_resume_plain(ctx)
787
- if len(text) <= RESUME_BUDGET_CHARS:
788
- break
831
+ if _host_len(text) <= budget:
832
+ return text
833
+
834
+ # Last resort: cut the core guide body to what is left, with a pointer.
835
+ over = _host_len(text) - budget
836
+ cores = ctx.get('core_memories') or []
837
+ if cores and not ctx.get('core_delivered'):
838
+ note = '\n…(core guide cut to fit; full text: memgit core show)'
839
+ total = sum(len(m.get('body') or '') for m in cores)
840
+ keep = max(0, total - over - len(note) * len(cores) - 50)
841
+ cut = []
842
+ for m in cores:
843
+ body = m.get('body') or ''
844
+ share = int(keep * len(body) / total) if total else 0
845
+ cut.append(dict(m, body=(body[:share].rstrip() + note) if share < len(body) else body))
846
+ ctx['core_memories'] = cut
847
+ text = _render_resume_plain(ctx)
848
+ if _host_len(text) > budget:
849
+ tail = '\n…(digest cut at the session-start budget)'
850
+ while _host_len(text) + _host_len(tail) > budget and text:
851
+ text = text[:max(0, len(text) - (_host_len(text) + _host_len(tail) - budget) - 1)]
852
+ text += tail
789
853
  return text
790
854
 
791
855
 
@@ -800,6 +864,8 @@ def _render_resume_plain(ctx: dict) -> str:
800
864
  return text if len(text) <= n else text[:n - 1] + '…'
801
865
 
802
866
  crit_clip = int(ctx.get('_crit_clip', 200))
867
+ crit_max = int(ctx.get('_crit_max', 20))
868
+ tracker_clip = int(ctx.get('_tracker_clip', 160))
803
869
 
804
870
  proj = f' · project {ctx["project"]}' if ctx.get('project') else ''
805
871
  lines = [
@@ -827,7 +893,20 @@ def _render_resume_plain(ctx: dict) -> str:
827
893
  body = (m.get('body') or m['rule']).rstrip()
828
894
  lines.append('')
829
895
  lines.append(body)
830
- # Status board: live entity state. Rendered right after the guide because
896
+ # Critical rules right after the guide, before the status board: a host
897
+ # that previews only the head of the digest must still see the rules,
898
+ # ranked (see Repository.resume_context) so the cut keeps the ones most
899
+ # likely to be broken.
900
+ if ctx.get('critical_memories'):
901
+ lines.append('')
902
+ lines.append('## Critical rules — always apply')
903
+ crit = ctx['critical_memories']
904
+ for m in crit[:crit_max]:
905
+ lines.append(f'- {m["slug"]}: {clip(m["rule"], crit_clip)}')
906
+ if len(crit) > crit_max:
907
+ lines.append(f'- …and {len(crit) - crit_max} more — run list_memories '
908
+ 'with min_priority=3')
909
+ # Status board: live entity state. Rendered right after the rules because
831
910
  # orientation ("what is the CURRENT state of things") is worth more than
832
911
  # history. The freshness stamp is what tells the model to trust or
833
912
  # re-verify a line.
@@ -840,7 +919,7 @@ def _render_resume_plain(ctx: dict) -> str:
840
919
  stamp = m['timestamp'].strftime('%m-%d')
841
920
  age = _tracker_age_days(m['timestamp'])
842
921
  stale = f' ⚠ {age}d old' if age >= STATUS_STALE_DAYS else ''
843
- lines.append(f'- {m["slug"]} (upd {stamp}{stale}): {clip(m["rule"], 160)}')
922
+ lines.append(f'- {m["slug"]} (upd {stamp}{stale}): {clip(m["rule"], tracker_clip)}')
844
923
  lines.append('(State changed? Update the tracker: save_memory with the '
845
924
  'same slug. ⚠ = not updated recently, may lag the files.)')
846
925
  st = ctx['staged']
@@ -869,14 +948,6 @@ def _render_resume_plain(ctx: dict) -> str:
869
948
  if m.get('project') and m['project'] != ctx.get('project') else '')
870
949
  unv = ' ⚠unverified' if m.get('unverified') else ''
871
950
  lines.append(f'- {m["slug"]} ({m["type"]}, {ts}){other}{unv}: {clip(m["rule"], 160)}')
872
- if ctx['critical_memories']:
873
- lines.append('')
874
- lines.append('## Critical rules — always apply')
875
- crit = ctx['critical_memories']
876
- for m in crit[:20]:
877
- lines.append(f'- {m["slug"]}: {clip(m["rule"], crit_clip)}')
878
- if len(crit) > 20:
879
- lines.append(f'- …and {len(crit) - 20} more — run list_memories with min_priority=3')
880
951
  if ctx.get('maintenance'):
881
952
  lines.append('')
882
953
  lines.append(f'## Maintenance needed\n- {ctx["maintenance"]}')
@@ -940,6 +1011,34 @@ def hook_stop_guard():
940
1011
  sys.exit(stop_guard())
941
1012
 
942
1013
 
1014
+ @hook.command('action-gate')
1015
+ def hook_action_gate():
1016
+ """PreToolUse: refuse or annotate calls governed by a rule's triggers (stdin JSON)."""
1017
+ from .hooks import action_gate
1018
+ sys.exit(action_gate())
1019
+
1020
+
1021
+ @cli.command('allow')
1022
+ @click.argument('slug')
1023
+ @click.option('--minutes', '-m', default=15, show_default=True,
1024
+ help='How long the override lasts (max 120)')
1025
+ def allow_cmd(slug, minutes):
1026
+ """Let calls governed by SLUG's triggers through for a short window.
1027
+
1028
+ For when the user has explicitly asked for an action a saved rule would
1029
+ refuse. The override is logged, and the session's Stop check counts it as
1030
+ a decision rather than a violation.
1031
+ """
1032
+ from .gate import allow
1033
+ repo = _require_repo()
1034
+ if repo.get(slug) is None:
1035
+ err.print(f'[red]no memory with slug {slug!r}[/red]')
1036
+ sys.exit(1)
1037
+ until = allow(repo, slug, minutes)
1038
+ console.print(f'[yellow]allowed[/yellow] calls governed by [cyan]{slug}[/cyan] '
1039
+ f'until {until.strftime("%H:%M:%S")} UTC')
1040
+
1041
+
943
1042
  @hook.command('context-recall')
944
1043
  def hook_context_recall():
945
1044
  """PostToolUse: hint when memories exist about the file being read (stdin JSON)."""
@@ -2507,8 +2606,13 @@ def gc(dry_run, squash_keep, reflog_keep, fmt_json):
2507
2606
  help='With --repair-markup: only this project')
2508
2607
  @click.option('--yes', 'confirm', is_flag=True,
2509
2608
  help='With --repair-markup: write the repair as one checkpoint')
2609
+ @click.option('--rules', 'check_rules', is_flag=True,
2610
+ help="Compare this project's critical rules with the rule files "
2611
+ 'its hosts load (CLAUDE.md, AGENTS.md, ...) and list '
2612
+ 'same-topic passages to check for contradictions')
2510
2613
  def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
2511
- full_audit, fmt_json, repair_markup, repair_project, confirm):
2614
+ full_audit, fmt_json, repair_markup, repair_project, confirm,
2615
+ check_rules):
2512
2616
  """Diagnose and repair store hygiene: provenance, usage ledger, caches.
2513
2617
 
2514
2618
  With no options, prints a report: quarantined (`_unknown`) and
@@ -2595,6 +2699,10 @@ def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
2595
2699
  acted = True
2596
2700
  _repair_markup(repo, repair_project, confirm)
2597
2701
 
2702
+ if check_rules:
2703
+ acted = True
2704
+ _doctor_rules(repo, fmt_json)
2705
+
2598
2706
  if acted:
2599
2707
  return
2600
2708
 
@@ -2656,6 +2764,31 @@ def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
2656
2764
  else:
2657
2765
  console.print('no memory carries tool-call markup in its fields')
2658
2766
 
2767
+ # Critical behaviour rules with no trigger are enforced only by recall —
2768
+ # the backlog the action gate cannot cover until each gets one.
2769
+ from .gate import needs_trigger
2770
+ from .links import filter_active
2771
+ from .project import detect_project, project_affinity
2772
+ here = detect_project()
2773
+ untrig = [m for m in filter_active(mnemonics)
2774
+ if needs_trigger(m.type_code, m.priority) and not m.triggers
2775
+ and not m.unverified]
2776
+ trig = [m for m in filter_active(mnemonics) if m.triggers]
2777
+ if untrig:
2778
+ mine = [m for m in untrig
2779
+ if here and m.project and project_affinity(m.project, here) >= 1]
2780
+ console.print(f'[yellow]{len(untrig)} critical behaviour rules (p3 fb/cn) '
2781
+ f'have no triggers[/yellow] — the action gate cannot '
2782
+ f'enforce them; {len(trig)} memories carry triggers')
2783
+ for m in sorted(mine, key=lambda m: m.timestamp, reverse=True)[:10]:
2784
+ console.print(f' [dim]{m.slug}[/dim]')
2785
+ if mine:
2786
+ console.print(f' [dim]({len(mine)} in {here}; add triggers with '
2787
+ f'save_memory triggers=[...])[/dim]')
2788
+ else:
2789
+ console.print(f'every critical behaviour rule carries triggers '
2790
+ f'({len(trig)} memories with triggers)')
2791
+
2659
2792
  from .audit import audit as _audit
2660
2793
  rep = _audit(repo)
2661
2794
  _render_scope_losses(rep, full_audit)
@@ -2663,6 +2796,45 @@ def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
2663
2796
  console.print_json(data=rep)
2664
2797
 
2665
2798
 
2799
+ def _doctor_rules(repo, fmt_json: bool = False) -> None:
2800
+ """Report same-topic pairs between critical rules and host rule files."""
2801
+ import json as _j
2802
+ from .drift import find_drift, rule_files
2803
+ from .project import detect_project
2804
+ project = detect_project()
2805
+ files = rule_files(Path.cwd())
2806
+ pairs = find_drift(repo.list(), files, project) if project else []
2807
+ if fmt_json:
2808
+ print(_j.dumps({
2809
+ 'project': project,
2810
+ 'files': [str(f) for f in files],
2811
+ 'pairs': [{'slug': d.slug, 'rule': d.rule, 'file': str(d.passage.path),
2812
+ 'line': d.passage.line, 'passage': d.passage.text,
2813
+ 'score': d.score} for d in pairs],
2814
+ }, indent=2))
2815
+ return
2816
+ console.print(f'[bold]memgit doctor --rules[/bold] — project {project or "(none)"}, '
2817
+ f'{len(files)} rule file(s)')
2818
+ for f in files:
2819
+ console.print(f' [dim]{f}[/dim]')
2820
+ if not project:
2821
+ console.print('[yellow]no project detected here — run from the project '
2822
+ 'directory[/yellow]')
2823
+ return
2824
+ if not pairs:
2825
+ console.print('no rule-file passage shares a topic with a critical rule')
2826
+ return
2827
+ console.print(f'{len(pairs)} same-topic pair(s). Each needs a reader: if the '
2828
+ 'file says the opposite of the rule, change the file or '
2829
+ 'supersede the rule, so the two stop disagreeing.\n')
2830
+ for d in pairs:
2831
+ rule = d.rule if len(d.rule) <= 200 else d.rule[:199] + '…'
2832
+ text = d.passage.text if len(d.passage.text) <= 240 else d.passage.text[:239] + '…'
2833
+ console.print(f'[cyan]{d.slug}[/cyan] [dim](score {d.score})[/dim]')
2834
+ console.print(f' rule: {rule}')
2835
+ console.print(f' {d.passage.path}:{d.passage.line}: {text}\n')
2836
+
2837
+
2666
2838
  def _repair_markup(repo, project, confirm) -> None:
2667
2839
  """Split damaged memories back into their fields; dry run without confirm.
2668
2840
 
@@ -3480,7 +3652,7 @@ def setup_antigravity(dry_run):
3480
3652
 
3481
3653
  #: substrings identifying a hook command as one of ours (any generation)
3482
3654
  _MEMGIT_HOOK_SIGNS = ('resume --plain', 'hook prompt-recall', 'hook stop-guard',
3483
- 'hook context-recall', ' sync')
3655
+ 'hook context-recall', 'hook action-gate', ' sync')
3484
3656
 
3485
3657
 
3486
3658
  def _is_memgit_hook_entry(h: dict) -> bool:
@@ -3499,11 +3671,13 @@ def _is_memgit_hook_entry(h: dict) -> bool:
3499
3671
  help='Skip the end-of-session capture guard (Stop)')
3500
3672
  @click.option('--no-ctx-recall', is_flag=True,
3501
3673
  help='Skip the context-recall hook (PostToolUse on Read/Grep/Glob)')
3674
+ @click.option('--no-gate', is_flag=True,
3675
+ help='Skip the action gate (PreToolUse on Bash and file writes)')
3502
3676
  @click.option('--dry-run', is_flag=True, help='Show the change without writing')
3503
- def setup_hooks(remove, no_recall, no_guard, no_ctx_recall, dry_run):
3677
+ def setup_hooks(remove, no_recall, no_guard, no_ctx_recall, no_gate, dry_run):
3504
3678
  """Install the Claude Code hooks that make memory automatic.
3505
3679
 
3506
- Five hooks, one principle: what a hook enforces happens, what a tool
3680
+ Six hooks, one principle: what a hook enforces happens, what a tool
3507
3681
  description suggests mostly doesn't (measured: 6% voluntary engagement
3508
3682
  vs 100% hook delivery).
3509
3683
 
@@ -3513,13 +3687,16 @@ def setup_hooks(remove, no_recall, no_guard, no_ctx_recall, dry_run):
3513
3687
  memory index
3514
3688
  UserPromptSubmit inject memories relevant to each prompt (BM25,
3515
3689
  silent when nothing clears the relevance bar)
3690
+ PreToolUse action gate — a Bash or file-write call matching a
3691
+ memory's triggers is refused (critical rules) or
3692
+ annotated (the rest); trigger cache only
3516
3693
  PostToolUse context recall — reading a file whose path matches
3517
3694
  a memory tag surfaces a one-line depth hint
3518
3695
  (tagmap cache only, capped 3/session)
3519
- Stop capture guard — a substantive session ending with
3520
- zero memory writes gets ONE nudge to save durable
3521
- facts; plus async `memgit sync` to checkpoint
3522
- markdown memories
3696
+ Stop compliance replay + capture guard — a governed call
3697
+ that went through, or a substantive session ending
3698
+ with zero memory writes, gets ONE block; plus async
3699
+ `memgit sync` to checkpoint markdown memories
3523
3700
 
3524
3701
  Hooks live in ~/.claude/settings.json (unlike MCP servers, which live
3525
3702
  in ~/.claude.json).
@@ -3550,6 +3727,10 @@ def setup_hooks(remove, no_recall, no_guard, no_ctx_recall, dry_run):
3550
3727
  {'type': 'command',
3551
3728
  'command': f'{base} hook prompt-recall 2>/dev/null || true'},
3552
3729
  ],
3730
+ 'PreToolUse': [] if no_gate else [
3731
+ {'type': 'command',
3732
+ 'command': f'{base} hook action-gate 2>/dev/null || true'},
3733
+ ],
3553
3734
  'PostToolUse': [] if no_ctx_recall else [
3554
3735
  {'type': 'command',
3555
3736
  'command': f'{base} hook context-recall 2>/dev/null || true'},
@@ -3569,10 +3750,11 @@ def setup_hooks(remove, no_recall, no_guard, no_ctx_recall, dry_run):
3569
3750
  }
3570
3751
  #: PostToolUse fires per tool call — matcher limits it to the file-reading
3571
3752
  #: tools so the hook binary isn't spawned on every Bash/Edit.
3572
- _MATCHERS = {'PostToolUse': 'Read|Grep|Glob'}
3753
+ from .gate import GATE_MATCHER
3754
+ _MATCHERS = {'PreToolUse': GATE_MATCHER, 'PostToolUse': 'Read|Grep|Glob'}
3573
3755
 
3574
3756
  changed = []
3575
- for event in ('SessionStart', 'UserPromptSubmit', 'PostToolUse', 'Stop'):
3757
+ for event in ('SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'):
3576
3758
  entries = hooks.setdefault(event, [])
3577
3759
  had = [h for h in entries if isinstance(h, dict) and _is_memgit_hook_entry(h)]
3578
3760
  entries[:] = [h for h in entries if h not in had]
@@ -3600,7 +3782,9 @@ def setup_hooks(remove, no_recall, no_guard, no_ctx_recall, dry_run):
3600
3782
  tag = ' [dim](async)[/dim]' if inner.get('async') else ''
3601
3783
  console.print(f' [cyan]{event}[/cyan] [dim]{inner["command"]}[/dim]{tag}')
3602
3784
  console.print('[dim]Resume at session start, relevant memories per prompt, '
3603
- 'context hints on file reads, capture guard + sync at stop.[/dim]')
3785
+ 'rule triggers checked before Bash and file writes, '
3786
+ 'context hints on file reads, compliance + capture guard + '
3787
+ 'sync at stop.[/dim]')
3604
3788
 
3605
3789
 
3606
3790
  @setup.command('print-config')
@@ -3758,8 +3942,8 @@ except Exception:
3758
3942
  # extras missing or broken — plain memgit must keep working untouched
3759
3943
  @cli.group()
3760
3944
  def cloud():
3761
- """E2E-encrypted team sync — requires: pip install 'memgit[cloud]'"""
3945
+ """E2E-encrypted team sync — its dependencies failed to import; reinstall memgit."""
3762
3946
 
3763
3947
  @cloud.command('setup', help="Show how to enable cloud sync.")
3764
3948
  def _cloud_setup_hint():
3765
- console.print(r"Install the cloud extra first: [bold]pip install 'memgit\[cloud]'[/bold]")
3949
+ console.print("Cloud sync could not load pynacl or httpx. Reinstall: [bold]pip install --upgrade --force-reinstall memgit[/bold]")
@@ -0,0 +1,7 @@
1
+ """memgit cloud — E2E-encrypted team sync (app.memgit.dev).
2
+
3
+ Nothing here runs until a store is logged in and linked. The rest of memgit
4
+ imports only `auto`, lazily, to start a background sync after a commit, at
5
+ session start, and from a read when the last sync is old. pynacl and httpx are
6
+ ordinary dependencies of the package.
7
+ """