memoryledger 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.
@@ -0,0 +1,463 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import uuid
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ try:
10
+ import tomllib
11
+ except ModuleNotFoundError: # pragma: no cover
12
+ import tomli as tomllib # type: ignore[no-redef]
13
+ import yaml
14
+ from ledgercore.atomic import atomic_write_text
15
+ from ledgercore.time import utc_now_iso
16
+
17
+ from .errors import MemoryledgerError
18
+ from .guardrails import confined_path, validate_memory, validate_scope_path
19
+ from .models import Config, EvidenceRef, Memory, RenderConfig, TemplatePolicy
20
+
21
+ CONFIG_NAMES = ("memoryledger.toml", ".memoryledger.toml")
22
+
23
+
24
+ def _dump_yaml(data: dict[str, object]) -> str:
25
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
26
+
27
+
28
+ def _load_yaml(path: Path) -> dict[str, Any]:
29
+ if not path.exists():
30
+ return {}
31
+ data = yaml.safe_load(path.read_text())
32
+ return data if isinstance(data, dict) else {}
33
+
34
+
35
+ def _load_evidence_refs(path: Path) -> list[EvidenceRef]:
36
+ raw = yaml.safe_load(path.read_text()) if path.exists() else {}
37
+ if isinstance(raw, dict):
38
+ items = raw.get("evidence", [])
39
+ elif isinstance(raw, list):
40
+ items = raw
41
+ else:
42
+ items = []
43
+ return [EvidenceRef.from_dict(item) for item in items if isinstance(item, dict)]
44
+
45
+
46
+ def _load_memory(path: Path) -> Memory:
47
+ data = _load_yaml(path / "memory.yaml")
48
+ if "evidence_refs" not in data:
49
+ refs = _load_evidence_refs(path / "evidence.yaml")
50
+ if refs:
51
+ data = {**data, "evidence_refs": [ref.to_dict() for ref in refs]}
52
+ return Memory.from_dict(data)
53
+
54
+
55
+ def _load_toml(path: Path) -> dict[str, Any]:
56
+ data = tomllib.loads(path.read_text())
57
+ return data if isinstance(data, dict) else {}
58
+
59
+
60
+ def _global_config_path() -> Path:
61
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
62
+ return base / "ledger" / "memoryledger.toml"
63
+
64
+
65
+ def _merged_section(
66
+ global_data: dict[str, Any], project_data: dict[str, Any], key: str
67
+ ) -> dict[str, Any]:
68
+ merged: dict[str, Any] = {}
69
+ raw_global = global_data.get(key, {})
70
+ if isinstance(raw_global, dict):
71
+ merged.update(raw_global)
72
+ raw_project = project_data.get(key, {})
73
+ if isinstance(raw_project, dict):
74
+ merged.update(raw_project)
75
+ return merged
76
+
77
+
78
+ def _parse_template_policy(data: dict[str, Any]) -> TemplatePolicy:
79
+ raw = data.get("template_policy", {})
80
+ if not isinstance(raw, dict):
81
+ return TemplatePolicy()
82
+ enabled_raw = raw.get("enabled", False)
83
+ ids: list[str] = []
84
+ enabled = False
85
+ if isinstance(enabled_raw, list):
86
+ ids.extend(str(value) for value in enabled_raw)
87
+ enabled = bool(ids)
88
+ else:
89
+ enabled = bool(enabled_raw)
90
+ raw_ids = raw.get("ids", [])
91
+ if isinstance(raw_ids, list):
92
+ for value in raw_ids:
93
+ item = str(value)
94
+ if item not in ids:
95
+ ids.append(item)
96
+ auto_accept = bool(raw.get("auto_accept", False))
97
+ return TemplatePolicy(
98
+ enabled=enabled or bool(ids), ids=ids, auto_accept=auto_accept
99
+ )
100
+
101
+
102
+ def default_config_text(project_name: str, memoryledger_dir: str) -> str:
103
+ project_uuid = str(uuid.uuid4())
104
+ return f'''[ledger]
105
+ code = "ml"
106
+ name = "memoryledger"
107
+
108
+ [project]
109
+ name = "{project_name}"
110
+ uuid = "{project_uuid}"
111
+
112
+ [storage]
113
+ memoryledger_dir = "{memoryledger_dir}"
114
+
115
+ [render]
116
+ root_agents_path = "AGENTS.md"
117
+ linked_docs_dir = "docs/agents"
118
+ nested_agents_enabled = false
119
+ linked_docs_enabled = true
120
+ max_root_agents_chars = 12000
121
+ max_linked_doc_chars = 50000
122
+ include_local = false
123
+ include_rejected = false
124
+ sort_order = ["rule", "procedure", "semantic", "learning", "episode", "document"]
125
+
126
+ [intake]
127
+ allow_run_html = true
128
+ allow_current_run = true
129
+ default_review_status = "candidate"
130
+ '''
131
+
132
+
133
+ def find_config(start: Path | None = None) -> Path | None:
134
+ current = (start or Path.cwd()).resolve()
135
+ for directory in (current, *current.parents):
136
+ for name in CONFIG_NAMES:
137
+ path = directory / name
138
+ if path.exists():
139
+ return path
140
+ return None
141
+
142
+
143
+ def load_config(start: Path | None = None) -> Config:
144
+ path = find_config(start)
145
+ if path is None:
146
+ raise MemoryledgerError("NO_CONFIG", "Run `memoryledger init` first.")
147
+ project_data = _load_toml(path)
148
+ global_path = _global_config_path()
149
+ global_data = _load_toml(global_path) if global_path.exists() else {}
150
+ render_data = _merged_section(global_data, project_data, "render")
151
+ ledger_data = _merged_section(global_data, project_data, "ledger")
152
+ project_section = _merged_section(global_data, project_data, "project")
153
+ storage_data = _merged_section(global_data, project_data, "storage")
154
+ intake_data = _merged_section(global_data, project_data, "intake")
155
+ render = RenderConfig(
156
+ **{
157
+ k: v
158
+ for k, v in render_data.items()
159
+ if k in RenderConfig.__dataclass_fields__
160
+ }
161
+ )
162
+ config = Config(
163
+ root=path.parent,
164
+ config_path=path,
165
+ ledger_code=str(ledger_data.get("code", "ml")),
166
+ ledger_name=str(ledger_data.get("name", "memoryledger")),
167
+ project_name=str(project_section.get("name", "my-project")),
168
+ project_uuid=str(project_section.get("uuid", "")),
169
+ memoryledger_dir=str(storage_data.get("memoryledger_dir", ".memoryledger")),
170
+ render=render,
171
+ allow_run_html=bool(intake_data.get("allow_run_html", True)),
172
+ allow_current_run=bool(intake_data.get("allow_current_run", True)),
173
+ default_review_status=str(
174
+ intake_data.get("default_review_status", "candidate")
175
+ ),
176
+ template_policy=_parse_template_policy(
177
+ {
178
+ "template_policy": {
179
+ **(
180
+ global_data.get("template_policy", {})
181
+ if isinstance(global_data.get("template_policy", {}), dict)
182
+ else {}
183
+ ),
184
+ **(
185
+ project_data.get("template_policy", {})
186
+ if isinstance(project_data.get("template_policy", {}), dict)
187
+ else {}
188
+ ),
189
+ }
190
+ }
191
+ ),
192
+ )
193
+ confined_path(config.root, config.memoryledger_dir, label="memoryledger_dir")
194
+ confined_path(
195
+ config.root,
196
+ config.render.root_agents_path,
197
+ code="INVALID_OUTPUT_PATH",
198
+ label="root_agents_path",
199
+ )
200
+ confined_path(
201
+ config.root,
202
+ config.render.linked_docs_dir,
203
+ code="INVALID_OUTPUT_PATH",
204
+ label="linked_docs_dir",
205
+ )
206
+ if config.render.evidence_index_path:
207
+ confined_path(
208
+ config.root,
209
+ config.render.evidence_index_path,
210
+ code="INVALID_OUTPUT_PATH",
211
+ label="evidence_index_path",
212
+ )
213
+ return config
214
+
215
+
216
+ def init_workspace(
217
+ project_name: str | None, memoryledger_dir: str, hidden_config: bool
218
+ ) -> Config:
219
+ root = Path.cwd().resolve()
220
+ config_path = root / (
221
+ ".memoryledger.toml" if hidden_config else "memoryledger.toml"
222
+ )
223
+ if not config_path.exists():
224
+ atomic_write_text(
225
+ config_path,
226
+ default_config_text(project_name or root.name, memoryledger_dir),
227
+ )
228
+ config = load_config(root)
229
+ ensure_storage(config)
230
+ return config
231
+
232
+
233
+ def ensure_storage(config: Config) -> None:
234
+ for rel in ["memories", "imports", "rendered/docs", "rendered/nested"]:
235
+ (config.storage_dir / rel).mkdir(parents=True, exist_ok=True)
236
+ storage = config.storage_dir / "storage.yaml"
237
+ if not storage.exists():
238
+ atomic_write_text(
239
+ storage, _dump_yaml({"next_memory_number": 1, "next_import_number": 1})
240
+ )
241
+
242
+
243
+ class Store:
244
+ def __init__(self, config: Config) -> None:
245
+ self.config = config
246
+ ensure_storage(config)
247
+
248
+ def storage_meta(self) -> dict[str, Any]:
249
+ return _load_yaml(self.config.storage_dir / "storage.yaml")
250
+
251
+ def write_storage_meta(self, data: dict[str, object]) -> None:
252
+ atomic_write_text(self.config.storage_dir / "storage.yaml", _dump_yaml(data))
253
+
254
+ def next_id(self) -> str:
255
+ meta = self.storage_meta()
256
+ number = int(meta.get("next_memory_number", 1))
257
+ meta["next_memory_number"] = number + 1
258
+ self.write_storage_meta(meta)
259
+ return f"memory-{number:04d}"
260
+
261
+ def memory_dir(self, memory_id: str) -> Path:
262
+ return self.config.storage_dir / "memories" / memory_id
263
+
264
+ def all_memories(self) -> list[Memory]:
265
+ items: list[Memory] = []
266
+ base = self.config.storage_dir / "memories"
267
+ for path in sorted(base.glob("memory-*/memory.yaml")):
268
+ items.append(_load_memory(path.parent))
269
+ return items
270
+
271
+ def get(self, memory_id: str) -> Memory:
272
+ path = self.memory_dir(memory_id) / "memory.yaml"
273
+ if not path.exists():
274
+ raise MemoryledgerError("NOT_FOUND", f"Memory not found: {memory_id}")
275
+ return _load_memory(path.parent)
276
+
277
+ def read_content(self, memory_id: str) -> str:
278
+ return (self.memory_dir(memory_id) / "content.md").read_text()
279
+
280
+ def read_evidence(self, memory_id: str) -> str:
281
+ path = self.memory_dir(memory_id) / "evidence.md"
282
+ return path.read_text() if path.exists() else ""
283
+
284
+ def create(
285
+ self,
286
+ kind: str,
287
+ title: str,
288
+ content: str,
289
+ evidence: str,
290
+ scope: str,
291
+ scope_path: str,
292
+ render_target: str,
293
+ source: str = "cli",
294
+ *,
295
+ origin: str = "",
296
+ origin_hash: str = "",
297
+ section: str = "",
298
+ evidence_refs: list[EvidenceRef] | None = None,
299
+ ) -> Memory:
300
+ memory = self.validate_new(
301
+ kind,
302
+ title,
303
+ content,
304
+ evidence,
305
+ scope,
306
+ scope_path,
307
+ render_target,
308
+ source,
309
+ origin=origin,
310
+ origin_hash=origin_hash,
311
+ section=section,
312
+ evidence_refs=evidence_refs,
313
+ )
314
+ memory = replace(memory, id=self.next_id())
315
+ self.write(memory, content, evidence, "created")
316
+ return memory
317
+
318
+ def validate_new(
319
+ self,
320
+ kind: str,
321
+ title: str,
322
+ content: str,
323
+ evidence: str,
324
+ scope: str,
325
+ scope_path: str,
326
+ render_target: str,
327
+ source: str = "cli",
328
+ *,
329
+ origin: str = "",
330
+ origin_hash: str = "",
331
+ section: str = "",
332
+ evidence_refs: list[EvidenceRef] | None = None,
333
+ ) -> Memory:
334
+ now = utc_now_iso()
335
+ memory = Memory(
336
+ "memory-pending",
337
+ kind,
338
+ title,
339
+ "candidate",
340
+ 100,
341
+ scope,
342
+ scope_path,
343
+ render_target,
344
+ source,
345
+ now,
346
+ now,
347
+ 1,
348
+ [],
349
+ origin,
350
+ origin_hash,
351
+ section,
352
+ evidence_refs or [],
353
+ )
354
+ validate_scope_path(self.config.root, scope_path)
355
+ validate_memory(memory, content, evidence, self.config.root)
356
+ return memory
357
+
358
+ def write(self, memory: Memory, content: str, evidence: str, reason: str) -> None:
359
+ validate_scope_path(self.config.root, memory.scope_path)
360
+ validate_memory(memory, content, evidence, self.config.root)
361
+ mdir = self.memory_dir(memory.id)
362
+ (mdir / "versions").mkdir(parents=True, exist_ok=True)
363
+ atomic_write_text(mdir / "memory.yaml", _dump_yaml(memory.to_dict()))
364
+ atomic_write_text(mdir / "content.md", content.rstrip() + "\n")
365
+ atomic_write_text(
366
+ mdir / "evidence.md", evidence.rstrip() + "\n" if evidence else ""
367
+ )
368
+ evidence_yaml = mdir / "evidence.yaml"
369
+ if memory.evidence_refs:
370
+ atomic_write_text(
371
+ evidence_yaml,
372
+ _dump_yaml(
373
+ {"evidence": [ref.to_dict() for ref in memory.evidence_refs]}
374
+ ),
375
+ )
376
+ elif evidence_yaml.exists():
377
+ evidence_yaml.unlink()
378
+ version = f"v{memory.version:04d}"
379
+ atomic_write_text(
380
+ mdir / "versions" / f"{version}.yaml",
381
+ _dump_yaml(
382
+ {"memory": memory.to_dict(), "reason": reason, "evidence": evidence}
383
+ ),
384
+ )
385
+ atomic_write_text(mdir / "versions" / f"{version}.md", content.rstrip() + "\n")
386
+
387
+ def update_status(self, memory_id: str, status: str, reason: str) -> Memory:
388
+ old = self.get(memory_id)
389
+ refs = old.evidence_refs
390
+ if status == "accepted":
391
+ refs = [
392
+ *refs,
393
+ EvidenceRef(
394
+ kind="user_approval",
395
+ title="Review approval",
396
+ uri=f"memory:{memory_id}#review-v{old.version + 1:04d}",
397
+ excerpt=reason,
398
+ timestamp=utc_now_iso(),
399
+ ),
400
+ ]
401
+ new = replace(
402
+ old,
403
+ status=status,
404
+ evidence_refs=refs,
405
+ updated_at=utc_now_iso(),
406
+ version=old.version + 1,
407
+ )
408
+ self.write(
409
+ new,
410
+ self.read_content(memory_id),
411
+ self.read_evidence(memory_id) + f"\nReview reason: {reason}\n",
412
+ reason,
413
+ )
414
+ return new
415
+
416
+ def update_content(
417
+ self,
418
+ memory_id: str,
419
+ content: str,
420
+ reason: str,
421
+ append: bool = False,
422
+ section: str | None = None,
423
+ ) -> Memory:
424
+ old = self.get(memory_id)
425
+ body = (
426
+ self.read_content(memory_id).rstrip() + "\n" + content.rstrip() + "\n"
427
+ if append
428
+ else content.rstrip() + "\n"
429
+ )
430
+ new = replace(
431
+ old,
432
+ section=old.section if section is None else section,
433
+ updated_at=utc_now_iso(),
434
+ version=old.version + 1,
435
+ )
436
+ self.write(new, body, self.read_evidence(memory_id), reason)
437
+ return new
438
+
439
+ def add_evidence(
440
+ self, memory_id: str, evidence: EvidenceRef, reason: str
441
+ ) -> Memory:
442
+ if not reason.strip():
443
+ raise MemoryledgerError(
444
+ "MISSING_REASON", "evidence changes require a reason"
445
+ )
446
+ old = self.get(memory_id)
447
+ new = replace(
448
+ old,
449
+ evidence_refs=[*old.evidence_refs, evidence],
450
+ updated_at=utc_now_iso(),
451
+ version=old.version + 1,
452
+ )
453
+ self.write(
454
+ new, self.read_content(memory_id), self.read_evidence(memory_id), reason
455
+ )
456
+ return new
457
+
458
+ def next_import_id(self) -> str:
459
+ meta = self.storage_meta()
460
+ number = int(meta.get("next_import_number", 1))
461
+ meta["next_import_number"] = number + 1
462
+ self.write_storage_meta(meta)
463
+ return f"import-{number:04d}"
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ try:
10
+ import tomllib
11
+ except ModuleNotFoundError: # pragma: no cover
12
+ import tomli as tomllib # type: ignore[no-redef]
13
+
14
+ from ledgercore.time import utc_now_iso
15
+
16
+ from .errors import MemoryledgerError
17
+ from .guardrails import confined_path, validate_content
18
+ from .models import GlobalConfig, Memory, Template
19
+ from .storage import Store
20
+
21
+
22
+ def global_config_path() -> Path:
23
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
24
+ return base / "ledger" / "memoryledger.toml"
25
+
26
+
27
+ def canonical_hash(content: str) -> str:
28
+ return hashlib.sha256((content.rstrip() + "\n").encode()).hexdigest()
29
+
30
+
31
+ def load_global_config(path: Path | None = None) -> GlobalConfig:
32
+ config_path = path or global_config_path()
33
+ if not config_path.exists():
34
+ return GlobalConfig(config_path)
35
+ data = tomllib.loads(config_path.read_text())
36
+ raw_templates = data.get("templates", [])
37
+ if isinstance(raw_templates, dict):
38
+ raw_templates = [
39
+ {"id": template_id, **value} for template_id, value in raw_templates.items()
40
+ ]
41
+ templates: list[Template] = []
42
+ seen: set[str] = set()
43
+ for raw in raw_templates:
44
+ if not isinstance(raw, dict):
45
+ raise MemoryledgerError("INVALID_TEMPLATE", "template must be a table")
46
+ template = _parse_template(raw, config_path.parent)
47
+ if template.id in seen:
48
+ raise MemoryledgerError(
49
+ "DUPLICATE_TEMPLATE_ID", f"Duplicate template ID: {template.id}"
50
+ )
51
+ seen.add(template.id)
52
+ templates.append(template)
53
+ return GlobalConfig(config_path, templates)
54
+
55
+
56
+ def _parse_template(raw: dict[str, Any], root: Path) -> Template:
57
+ template = Template(
58
+ id=str(raw.get("id", "")),
59
+ version=str(raw.get("version", "1")),
60
+ kind=str(raw.get("kind", "rule")),
61
+ title=str(raw.get("title", "")),
62
+ content=str(raw.get("content", "")),
63
+ content_file=str(raw.get("content_file", "")),
64
+ scope=str(raw.get("scope", "global")),
65
+ scope_path=str(raw.get("scope_path", "")),
66
+ render_target=str(raw.get("render_target", "root_agents")),
67
+ section=str(raw.get("section", "")),
68
+ source_root=root,
69
+ )
70
+ if not template.id or not template.title:
71
+ raise MemoryledgerError(
72
+ "INVALID_TEMPLATE", "template ID and title are required"
73
+ )
74
+ return template
75
+
76
+
77
+ def template_content(template: Template) -> str:
78
+ if bool(template.content) == bool(template.content_file):
79
+ raise MemoryledgerError(
80
+ "INVALID_TEMPLATE", "provide exactly one of content or content_file"
81
+ )
82
+ content = template.content
83
+ if template.content_file:
84
+ path = confined_path(
85
+ template.source_root,
86
+ template.content_file,
87
+ code="INVALID_TEMPLATE_PATH",
88
+ label="template content_file",
89
+ must_exist=True,
90
+ )
91
+ content = path.read_text()
92
+ validate_content(content)
93
+ return content.rstrip() + "\n"
94
+
95
+
96
+ def find_template(config: GlobalConfig, template_id: str) -> Template:
97
+ for template in config.templates:
98
+ if template.id == template_id:
99
+ return template
100
+ raise MemoryledgerError("TEMPLATE_NOT_FOUND", template_id)
101
+
102
+
103
+ def apply_template(store: Store, template: Template) -> tuple[str, Memory]:
104
+ content = template_content(template)
105
+ digest = canonical_hash(content)
106
+ origin = f"template:{template.id}"
107
+ matches = [memory for memory in store.all_memories() if memory.origin == origin]
108
+ if not matches:
109
+ memory = store.create(
110
+ template.kind,
111
+ template.title,
112
+ content,
113
+ f"Applied template {template.id} version {template.version}.",
114
+ template.scope,
115
+ template.scope_path,
116
+ template.render_target,
117
+ "template",
118
+ origin=origin,
119
+ origin_hash=digest,
120
+ section=template.section,
121
+ )
122
+ return "created", memory
123
+ memory = matches[0]
124
+ local = store.read_content(memory.id)
125
+ if canonical_hash(local) != memory.origin_hash:
126
+ raise MemoryledgerError(
127
+ "TEMPLATE_CONFLICT", f"Local memory diverged from template: {memory.id}"
128
+ )
129
+ if memory.origin_hash == digest:
130
+ return "unchanged", memory
131
+ updated = replace(
132
+ memory,
133
+ kind=template.kind,
134
+ title=template.title,
135
+ status="candidate",
136
+ scope=template.scope,
137
+ scope_path=template.scope_path,
138
+ render_target=template.render_target,
139
+ section=template.section,
140
+ origin_hash=digest,
141
+ updated_at=utc_now_iso(),
142
+ version=memory.version + 1,
143
+ )
144
+ store.write(
145
+ updated,
146
+ content,
147
+ f"Synced template {template.id} version {template.version}.",
148
+ f"template sync {template.version}",
149
+ )
150
+ return "updated", updated
151
+
152
+
153
+ def remove_template(store: Store, template_id: str, reason: str) -> Memory:
154
+ matches = [
155
+ memory
156
+ for memory in store.all_memories()
157
+ if memory.origin == f"template:{template_id}"
158
+ ]
159
+ if not matches:
160
+ raise MemoryledgerError("TEMPLATE_NOT_APPLIED", template_id)
161
+ return store.update_status(matches[0].id, "archived", reason)
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: memoryledger
3
+ Version: 0.1.0
4
+ Summary: Auditable long-term project memory ledger and AGENTS.md renderer
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: typer
10
+ Requires-Dist: ledgercore<0.3,>=0.2
11
+ Requires-Dist: PyYAML
12
+ Requires-Dist: tomli; python_version < "3.11"
13
+ Provides-Extra: dev
14
+ Requires-Dist: build; extra == "dev"
15
+ Requires-Dist: mypy; extra == "dev"
16
+ Requires-Dist: pytest; extra == "dev"
17
+ Requires-Dist: pytest-xdist; extra == "dev"
18
+ Requires-Dist: ruff; extra == "dev"
19
+ Requires-Dist: twine; extra == "dev"
20
+ Requires-Dist: types-PyYAML; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # memoryledger
24
+
25
+ `memoryledger` is an auditable long-term project memory ledger and deterministic `AGENTS.md` renderer.
26
+
27
+ `AGENTS.md` is a generated entry point. Canonical durable memory lives in
28
+ `.memoryledger/`.
29
+
30
+ ## Quick start
31
+
32
+ ```bash
33
+ memoryledger init
34
+ memoryledger memory create --kind rule --title "Use plans" --text "Always plan before implementation."
35
+ memoryledger review accept memory-0001 --reason "User approved project rule."
36
+ memoryledger render --print
37
+ memoryledger export
38
+ ```
39
+
40
+ The short alias `memledger` exposes the same CLI.
41
+
42
+ To build or update `AGENTS.md`, update memory records first, then render and
43
+ export. Do not edit generated `AGENTS.md` files directly.
44
+
45
+ Rendered targets are owned only when configured and marked with
46
+ `<!-- Generated by memoryledger. Do not edit directly. -->`. Do not edit those
47
+ files directly. Files under `docs/agents/` without the marker may be manual.
48
+ Export refuses to overwrite any manual target, including when `--backup` is
49
+ passed. Use `memoryledger agents adopt` for a read-only preview and
50
+ `memoryledger agents adopt --apply --backup` for the dedicated adoption
51
+ transaction. Adoption creates candidates by default; `--accept` requires
52
+ `--reason`.
53
+
54
+ Example:
55
+
56
+ ```bash
57
+ memoryledger memory create --kind rule --title "Use memoryledger for AGENTS" --text "Do not edit AGENTS.md directly; update memoryledger records and export."
58
+ memoryledger review accept memory-0001 --reason "User approved project memory workflow."
59
+ memoryledger render
60
+ memoryledger export
61
+ ```
62
+
63
+ Additional intake commands are `templates list/show/apply/sync/remove`,
64
+ `evidence scan`, and `import run-html`. All producer output remains candidate
65
+ memory until explicit review. Structured evidence is managed with
66
+ `memory evidence list/add`. Both `memoryledger` and `memledger` expose the same
67
+ commands.