agent-wiki-cli 0.3.28__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.
Files changed (47) hide show
  1. agent_wiki_cli-0.3.28.dist-info/METADATA +425 -0
  2. agent_wiki_cli-0.3.28.dist-info/RECORD +47 -0
  3. agent_wiki_cli-0.3.28.dist-info/WHEEL +5 -0
  4. agent_wiki_cli-0.3.28.dist-info/entry_points.txt +2 -0
  5. agent_wiki_cli-0.3.28.dist-info/licenses/LICENSE +21 -0
  6. agent_wiki_cli-0.3.28.dist-info/top_level.txt +1 -0
  7. llm_wiki_cli/__init__.py +7 -0
  8. llm_wiki_cli/cli.py +231 -0
  9. llm_wiki_cli/commands/__init__.py +1 -0
  10. llm_wiki_cli/commands/bootstrap_cmd.py +1072 -0
  11. llm_wiki_cli/commands/bump_cmd.py +55 -0
  12. llm_wiki_cli/commands/context_cmd.py +427 -0
  13. llm_wiki_cli/commands/extract_cmd.py +745 -0
  14. llm_wiki_cli/commands/generate_prompt_cmd.py +89 -0
  15. llm_wiki_cli/commands/hook_cmd.py +161 -0
  16. llm_wiki_cli/commands/init_cmd.py +92 -0
  17. llm_wiki_cli/commands/lint_cmd.py +294 -0
  18. llm_wiki_cli/commands/migrate_cmd.py +892 -0
  19. llm_wiki_cli/commands/release_cmd.py +163 -0
  20. llm_wiki_cli/commands/status_cmd.py +70 -0
  21. llm_wiki_cli/commands/sync_cmd.py +521 -0
  22. llm_wiki_cli/commands/trigger_cmd.py +205 -0
  23. llm_wiki_cli/commands/uninstall_cmd.py +221 -0
  24. llm_wiki_cli/commands/upgrade_cmd.py +196 -0
  25. llm_wiki_cli/config.py +318 -0
  26. llm_wiki_cli/extractors/__init__.py +46 -0
  27. llm_wiki_cli/extractors/common.py +90 -0
  28. llm_wiki_cli/extractors/go_extractor.py +143 -0
  29. llm_wiki_cli/extractors/go_scripts/go.mod +3 -0
  30. llm_wiki_cli/extractors/go_scripts/main.go +668 -0
  31. llm_wiki_cli/extractors/python_extractor.py +346 -0
  32. llm_wiki_cli/extractors/rust_extractor.py +143 -0
  33. llm_wiki_cli/extractors/rust_scripts/Cargo.lock +110 -0
  34. llm_wiki_cli/extractors/rust_scripts/Cargo.toml +11 -0
  35. llm_wiki_cli/extractors/rust_scripts/src/main.rs +803 -0
  36. llm_wiki_cli/extractors/ts_extractor.py +206 -0
  37. llm_wiki_cli/extractors/ts_scripts/extract.js +485 -0
  38. llm_wiki_cli/extractors/ts_scripts/package.json +10 -0
  39. llm_wiki_cli/services/__init__.py +0 -0
  40. llm_wiki_cli/services/circuit_breaker.py +79 -0
  41. llm_wiki_cli/services/io.py +47 -0
  42. llm_wiki_cli/services/lockfile.py +60 -0
  43. llm_wiki_cli/services/packages.py +173 -0
  44. llm_wiki_cli/services/paths.py +31 -0
  45. llm_wiki_cli/services/schema.py +214 -0
  46. llm_wiki_cli/services/secure_file.py +22 -0
  47. llm_wiki_cli/services/versioning.py +193 -0
@@ -0,0 +1,521 @@
1
+ """Incremental wiki sync — update only pages whose source has changed.
2
+
3
+ Workflow:
4
+ 1. Load ``wiki_dir/.llm-wiki-manifest.json`` (error if missing — run bootstrap first).
5
+ 2. Hash every source file in the current AST inventory.
6
+ 3. Compute a diff: new / changed / unchanged / removed files, moved classes.
7
+ 4. Apply changes surgically: regenerate pages for new/changed files, add a
8
+ deprecation warning to pages whose source was removed, skip everything else.
9
+ 5. Rebuild index.md and append a log entry if anything changed.
10
+ 6. Save the updated manifest.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import sys
18
+ from dataclasses import dataclass, field
19
+ from datetime import date
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from .extract_cmd import get_inventory_result, infer_language_from_path, print_inventory_failures
24
+ from .bootstrap_cmd import (
25
+ _build_relationships,
26
+ _generate_entity_md,
27
+ _generate_index_md,
28
+ _generate_module_md,
29
+ _module_name_from_path,
30
+ _page_name_for_entity,
31
+ _page_name_for_module,
32
+ build_entity_page_map,
33
+ build_module_page_map,
34
+ )
35
+ from ..config import validate_path
36
+ from ..services.io import read_md, write_md
37
+
38
+ # ── Constants ─────────────────────────────────────────────────────────────────
39
+
40
+ MANIFEST_FILENAME = ".llm-wiki-manifest.json"
41
+ MANIFEST_VERSION = 2
42
+ _DEPRECATION_HEADER = (
43
+ "> ⚠️ **Stale:** Source no longer found in codebase. "
44
+ "Run `llm-wiki lint` to audit.\n\n"
45
+ )
46
+
47
+ # ── Manifest ──────────────────────────────────────────────────────────────────
48
+
49
+
50
+ @dataclass
51
+ class SyncManifest:
52
+ """Persistent record of what the wiki was generated from.
53
+
54
+ Schema v2::
55
+
56
+ {
57
+ "version": 1,
58
+ "sources": {
59
+ "src/models.py": {
60
+ "hash": "sha256:<hex>",
61
+ "language": "python",
62
+ "entities": ["User", "Role"],
63
+ "module_page": "models"
64
+ }
65
+ }
66
+ }
67
+ """
68
+
69
+ sources: dict[str, dict] = field(default_factory=dict)
70
+
71
+ # ── Persistence ───────────────────────────────────────────────────────────
72
+
73
+ @classmethod
74
+ def load(cls, wiki_dir: Path) -> "SyncManifest":
75
+ """Load manifest from *wiki_dir*; raise ``FileNotFoundError`` if absent."""
76
+ manifest_path = wiki_dir / MANIFEST_FILENAME
77
+ if not manifest_path.exists():
78
+ raise FileNotFoundError(manifest_path)
79
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
80
+ sources = data.get("sources", {})
81
+ for filepath, info in sources.items():
82
+ if "language" not in info:
83
+ info["language"] = infer_language_from_path(filepath)
84
+ return cls(sources=sources)
85
+
86
+ def save(self, wiki_dir: Path) -> None:
87
+ """Write manifest to *wiki_dir* atomically (write + rename)."""
88
+ manifest_path = wiki_dir / MANIFEST_FILENAME
89
+ tmp_path = manifest_path.with_suffix(".json.tmp")
90
+ payload = json.dumps(
91
+ {"version": MANIFEST_VERSION, "sources": self.sources},
92
+ indent=2,
93
+ sort_keys=True,
94
+ )
95
+ tmp_path.write_text(payload, encoding="utf-8")
96
+ tmp_path.replace(manifest_path)
97
+
98
+ # ── Factory ───────────────────────────────────────────────────────────────
99
+
100
+ @classmethod
101
+ def build_from_inventory(
102
+ cls,
103
+ inventory: dict,
104
+ src_dir: str,
105
+ entity_page_cache: dict[tuple[str, str], str],
106
+ module_page_map: dict[str, str],
107
+ ) -> "SyncManifest":
108
+ """Create a manifest that reflects the current inventory state."""
109
+ sources: dict[str, dict] = {}
110
+ for filepath, file_data in inventory.items():
111
+ sources[filepath] = {
112
+ "hash": _hash_file(Path(src_dir) / filepath),
113
+ "language": file_data.get("language") or infer_language_from_path(filepath),
114
+ "entities": [c["name"] for c in file_data.get("classes", [])],
115
+ "entity_pages": {
116
+ c["name"]: entity_page_cache.get((c["name"], filepath), c["name"])
117
+ for c in file_data.get("classes", [])
118
+ },
119
+ "module_page": module_page_map.get(filepath, _module_name_from_path(filepath)),
120
+ }
121
+ return cls(sources=sources)
122
+
123
+
124
+ # ── Helpers ───────────────────────────────────────────────────────────────────
125
+
126
+
127
+ def _hash_file(path: Path) -> str:
128
+ """Return a ``"sha256:<hexdigest>"`` fingerprint of *path*'s raw bytes.
129
+
130
+ If the file cannot be read (deleted between inventory scan and hash)
131
+ return an empty sentinel so the caller treats it as changed.
132
+ """
133
+ try:
134
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
135
+ return f"sha256:{digest}"
136
+ except OSError:
137
+ return ""
138
+
139
+
140
+ # ── Diff ──────────────────────────────────────────────────────────────────────
141
+
142
+
143
+ @dataclass
144
+ class SyncDiff:
145
+ """Categorised difference between the persisted manifest and live inventory."""
146
+
147
+ new_files: list[str] = field(default_factory=list)
148
+ changed_files: list[str] = field(default_factory=list)
149
+ unchanged_files: list[str] = field(default_factory=list)
150
+ removed_files: list[str] = field(default_factory=list)
151
+ # {class_name: (old_filepath, new_filepath)}
152
+ moved_entities: dict[str, tuple[str, str]] = field(default_factory=dict)
153
+
154
+ @property
155
+ def has_changes(self) -> bool:
156
+ return bool(
157
+ self.new_files
158
+ or self.changed_files
159
+ or self.removed_files
160
+ or self.moved_entities
161
+ )
162
+
163
+
164
+ def _compute_diff(manifest: SyncManifest, inventory: dict, src_dir: str) -> SyncDiff:
165
+ """Compare *manifest* against the live *inventory*.
166
+
167
+ Move detection: a class that appears in the manifest under one filepath
168
+ but now lives in a *different* filepath is considered moved rather than
169
+ deleted+created. Its source-file hash is therefore refreshed from the
170
+ *new* filepath.
171
+ """
172
+ diff = SyncDiff()
173
+
174
+ # Build reverse lookup: class_name → filepath (from old manifest)
175
+ old_cls_to_file: dict[str, str] = {}
176
+ for fp, info in manifest.sources.items():
177
+ for cls_name in info.get("entities", []):
178
+ old_cls_to_file[cls_name] = fp
179
+
180
+ # Build reverse lookup: class_name → filepath (from new inventory)
181
+ new_cls_to_file: dict[str, str] = {}
182
+ for fp, file_data in inventory.items():
183
+ for cls in file_data.get("classes", []):
184
+ new_cls_to_file[cls["name"]] = fp
185
+
186
+ # Detect moves: same class name, different file
187
+ for cls_name, old_fp in old_cls_to_file.items():
188
+ new_fp = new_cls_to_file.get(cls_name)
189
+ if new_fp is not None and new_fp != old_fp:
190
+ diff.moved_entities[cls_name] = (old_fp, new_fp)
191
+
192
+ # Categorise each file in the new inventory
193
+ for filepath, file_data in inventory.items():
194
+ if filepath not in manifest.sources:
195
+ diff.new_files.append(filepath)
196
+ else:
197
+ # Re-hash to detect content changes
198
+ current_hash = _hash_file(Path(src_dir) / filepath)
199
+ if current_hash != manifest.sources[filepath].get("hash", ""):
200
+ diff.changed_files.append(filepath)
201
+ else:
202
+ diff.unchanged_files.append(filepath)
203
+
204
+ # Detect removals: in manifest but not in new inventory
205
+ for filepath in manifest.sources:
206
+ if filepath not in inventory:
207
+ diff.removed_files.append(filepath)
208
+
209
+ return diff
210
+
211
+
212
+ # ── Apply ─────────────────────────────────────────────────────────────────────
213
+
214
+
215
+ @dataclass
216
+ class SyncResult:
217
+ created: int = 0
218
+ updated: int = 0
219
+ skipped: int = 0
220
+ deprecated: int = 0
221
+
222
+
223
+ def _collision_maps(
224
+ inventory: dict, src_dir: str
225
+ ) -> tuple[set[str], set[str], dict[tuple[str, str], str]]:
226
+ """Return (colliding_stems, colliding_cls, entity_page_name_cache).
227
+
228
+ Uses :func:`build_entity_page_map` for collision-aware entity names.
229
+ The first two sets are retained for API compatibility but are no
230
+ longer consumed directly.
231
+ """
232
+ entity_page_cache = build_entity_page_map(inventory)
233
+ return set(), set(), entity_page_cache
234
+
235
+
236
+ def _apply_diff(
237
+ diff: SyncDiff,
238
+ wiki_dir: Path,
239
+ inventory: dict,
240
+ src_dir: str,
241
+ manifest: SyncManifest,
242
+ ) -> SyncResult:
243
+ """Regenerate pages for new/changed files, deprecate pages for removed files."""
244
+ result = SyncResult()
245
+
246
+ # Full collision maps over the *entire* inventory
247
+ colliding_stems, colliding_cls, entity_page_cache = _collision_maps(inventory, src_dir)
248
+
249
+ # Module page map for the manifest builder: filepath → module_page_name
250
+ module_page_map: dict[str, str] = build_module_page_map(inventory)
251
+
252
+ # Re-build relationships from the full inventory using the same
253
+ # collision-aware module page names as bootstrap/migrate.
254
+ relationships = _build_relationships(inventory, module_page_map)
255
+
256
+ # ── New + changed files ────────────────────────────────────────────────────
257
+ for filepath in diff.new_files + diff.changed_files:
258
+ file_data = inventory[filepath]
259
+ mod_page_name = module_page_map.get(filepath, _page_name_for_module(filepath))
260
+
261
+ file_entity_page_map = {
262
+ cls["name"]: entity_page_cache[(cls["name"], filepath)]
263
+ for cls in file_data.get("classes", [])
264
+ }
265
+
266
+ # Entity pages
267
+ for cls in file_data.get("classes", []):
268
+ entity_page_name = file_entity_page_map[cls["name"]]
269
+ entity_path = wiki_dir / "entities" / f"{entity_page_name}.md"
270
+ content = _generate_entity_md(cls, filepath, relationships, mod_page_name)
271
+ is_new = not entity_path.exists()
272
+ write_md(entity_path, content)
273
+ if is_new:
274
+ result.created += 1
275
+ print(f" CREATE entity: {entity_page_name}")
276
+ else:
277
+ result.updated += 1
278
+ print(f" UPDATE entity: {entity_page_name}")
279
+
280
+ # Module page
281
+ module_path = wiki_dir / "modules" / f"{mod_page_name}.md"
282
+ content = _generate_module_md(filepath, file_data, file_entity_page_map)
283
+ is_new = not module_path.exists()
284
+ write_md(module_path, content)
285
+ if is_new:
286
+ result.created += 1
287
+ print(f" CREATE module: {mod_page_name}")
288
+ else:
289
+ result.updated += 1
290
+ print(f" UPDATE module: {mod_page_name}")
291
+
292
+ # ── Unchanged files ────────────────────────────────────────────────────────
293
+ for filepath in diff.unchanged_files:
294
+ mod_page_name = module_page_map.get(filepath, _page_name_for_module(filepath))
295
+ entity_count = len(inventory[filepath].get("classes", []))
296
+ result.skipped += 1 + entity_count # 1 module page + N entity pages
297
+ print(f" SKIP (unchanged): {_module_name_from_path(filepath)}")
298
+
299
+ # ── Removed files ──────────────────────────────────────────────────────────
300
+ for filepath in diff.removed_files:
301
+ old_info = manifest.sources[filepath]
302
+ deprecated_count = 0
303
+
304
+ for cls_name in old_info.get("entities", []):
305
+ entity_page_name = _removed_entity_page_name(wiki_dir, cls_name, filepath, old_info)
306
+
307
+ if entity_page_name:
308
+ entity_path = wiki_dir / "entities" / f"{entity_page_name}.md"
309
+ text = read_md(entity_path)
310
+ if _DEPRECATION_HEADER not in text:
311
+ write_md(entity_path, _DEPRECATION_HEADER + text)
312
+ deprecated_count += 1
313
+ result.deprecated += 1
314
+ print(f" DEPRECATE entity: {entity_page_name}")
315
+
316
+ # Module page deprecation
317
+ old_mod_page = old_info.get("module_page", _module_name_from_path(filepath))
318
+ mod_page_path = wiki_dir / "modules" / f"{old_mod_page}.md"
319
+
320
+ if mod_page_path.exists():
321
+ text = read_md(mod_page_path)
322
+ if _DEPRECATION_HEADER not in text:
323
+ write_md(mod_page_path, _DEPRECATION_HEADER + text)
324
+ result.deprecated += 1
325
+ print(f" DEPRECATE module: {mod_page_path.stem}")
326
+
327
+ return result
328
+
329
+
330
+ def _removed_entity_page_name(
331
+ wiki_dir: Path,
332
+ cls_name: str,
333
+ filepath: str,
334
+ old_info: dict,
335
+ ) -> Optional[str]:
336
+ """Resolve the existing entity page for a class whose source file was removed."""
337
+ entity_pages = old_info.get("entity_pages", {})
338
+ candidates: list[str] = []
339
+ if isinstance(entity_pages, dict) and entity_pages.get(cls_name):
340
+ candidates.append(str(entity_pages[cls_name]))
341
+
342
+ old_mod_page = old_info.get("module_page", _module_name_from_path(filepath))
343
+ if old_mod_page:
344
+ candidates.append(f"{old_mod_page}_{cls_name}")
345
+ candidates.append(cls_name)
346
+
347
+ seen: set[str] = set()
348
+ for page_name in candidates:
349
+ if page_name in seen:
350
+ continue
351
+ seen.add(page_name)
352
+ if (wiki_dir / "entities" / f"{page_name}.md").exists():
353
+ return page_name
354
+
355
+ matches = sorted((wiki_dir / "entities").glob(f"*_{cls_name}.md"))
356
+ return matches[0].stem if matches else None
357
+
358
+
359
+ # ── run ───────────────────────────────────────────────────────────────────────
360
+
361
+
362
+ def run(args) -> None:
363
+ src_dir: str = getattr(args, "src_dir", ".")
364
+ wiki_dir = Path(getattr(args, "wiki_dir", "docs/llm_wiki"))
365
+ validate_path(src_dir, "--src-dir")
366
+ validate_path(str(wiki_dir), "--wiki-dir")
367
+
368
+ # 1. Load manifest — seed one if the wiki exists but the manifest doesn't
369
+ # (migration path for projects bootstrapped by older llm-wiki versions)
370
+ try:
371
+ manifest = SyncManifest.load(wiki_dir)
372
+ except FileNotFoundError:
373
+ if (wiki_dir / "index.md").exists():
374
+ # Wiki was bootstrapped before manifests existed → seed baseline
375
+ print(
376
+ f"No sync manifest found — seeding from current source state.\n"
377
+ f"Existing wiki pages will NOT be modified.\n"
378
+ f"Future `llm-wiki sync` runs will update incrementally.\n"
379
+ )
380
+ inventory_result = get_inventory_result(src_dir, deep=True)
381
+ if inventory_result.failed:
382
+ print_inventory_failures(inventory_result)
383
+ sys.exit(1)
384
+ inventory = inventory_result.inventory
385
+ if not inventory:
386
+ print("No supported source files found; manifest not written.")
387
+ return
388
+ colliding_stems, colliding_cls, entity_page_cache = _collision_maps(
389
+ inventory, src_dir,
390
+ )
391
+ module_page_map = build_module_page_map(inventory)
392
+ seed = SyncManifest.build_from_inventory(
393
+ inventory, src_dir, entity_page_cache, module_page_map,
394
+ )
395
+ seed.save(wiki_dir)
396
+ print(f"Manifest written to {wiki_dir / MANIFEST_FILENAME}")
397
+ return
398
+ print(
399
+ f"Error: no sync manifest found at {wiki_dir / MANIFEST_FILENAME}.\n"
400
+ "Run `llm-wiki bootstrap` first to create the initial wiki and manifest.",
401
+ file=sys.stderr,
402
+ )
403
+ sys.exit(1)
404
+
405
+ print(f"Syncing wiki from source: {src_dir}")
406
+ print(f"Wiki directory: {wiki_dir}")
407
+
408
+ # 2. Extract current AST inventory (always deep for full page content)
409
+ inventory_result = get_inventory_result(src_dir, deep=True)
410
+ if inventory_result.failed:
411
+ print_inventory_failures(inventory_result)
412
+ sys.exit(1)
413
+ inventory = inventory_result.inventory
414
+
415
+ if not inventory and not manifest.sources:
416
+ print("No supported source files with classes or functions found.")
417
+ return
418
+
419
+ # 3. Compute diff
420
+ diff = _compute_diff(manifest, inventory, src_dir)
421
+
422
+ if not diff.has_changes:
423
+ print("Wiki is up to date.")
424
+ return
425
+
426
+ # 4. Apply changes
427
+ result = _apply_diff(diff, wiki_dir, inventory, src_dir, manifest)
428
+
429
+ # 5. Rebuild index.md
430
+ _rebuild_index(wiki_dir, inventory, src_dir)
431
+
432
+ # 6. Append log entry
433
+ _append_log(wiki_dir, src_dir, diff, result)
434
+
435
+ # 7. Compute collision maps + module page map for manifest, then save
436
+ colliding_stems, colliding_cls, entity_page_cache = _collision_maps(inventory, src_dir)
437
+ module_page_map = build_module_page_map(inventory)
438
+ updated_manifest = SyncManifest.build_from_inventory(
439
+ inventory, src_dir, entity_page_cache, module_page_map
440
+ )
441
+ updated_manifest.save(wiki_dir)
442
+
443
+ print(
444
+ f"\nSync complete: {result.created} created, {result.updated} updated, "
445
+ f"{result.skipped} skipped, {result.deprecated} deprecated."
446
+ )
447
+ if diff.moved_entities:
448
+ names = ", ".join(diff.moved_entities.keys())
449
+ print(f"Moved entities detected (pages updated in-place): {names}")
450
+
451
+
452
+ # ── Index + log helpers ───────────────────────────────────────────────────────
453
+
454
+
455
+ def _rebuild_index(wiki_dir: Path, inventory: dict, src_dir: str) -> None:
456
+ """Regenerate index.md from the live inventory."""
457
+ colliding_stems, colliding_cls, entity_page_cache = _collision_maps(inventory, src_dir)
458
+ mod_page_map = build_module_page_map(inventory)
459
+
460
+ all_entity_names: list[str] = []
461
+ seen: set[str] = set()
462
+ module_entries: list[dict] = []
463
+
464
+ for filepath, file_data in inventory.items():
465
+ mod_page_name = mod_page_map.get(filepath, _page_name_for_module(filepath))
466
+ module_entries.append({
467
+ "name": mod_page_name,
468
+ "path": filepath,
469
+ "docstring": file_data.get("module_docstring", ""),
470
+ })
471
+ for cls in file_data.get("classes", []):
472
+ page_name = entity_page_cache[(cls["name"], filepath)]
473
+ if page_name not in seen:
474
+ all_entity_names.append(page_name)
475
+ seen.add(page_name)
476
+
477
+ # Collect any existing workflow + infrastructure entries from disk
478
+ workflow_entries = _list_existing_pages(wiki_dir / "workflows", "entry")
479
+ infra_entries = _list_existing_pages(wiki_dir / "infrastructure", "type")
480
+
481
+ index_path = wiki_dir / "index.md"
482
+ write_md(index_path,
483
+ _generate_index_md(all_entity_names, module_entries, workflow_entries or None, infra_entries or None)
484
+ )
485
+ print(" WRITE index.md")
486
+
487
+
488
+ def _list_existing_pages(directory: Path, extra_key: str) -> list[dict]:
489
+ """Return a list of ``{"name": stem}`` dicts for every .md file in *directory*."""
490
+ if not directory.exists():
491
+ return []
492
+ return [{"name": p.stem, extra_key: ""} for p in sorted(directory.glob("*.md"))]
493
+
494
+
495
+ def _append_log(wiki_dir: Path, src_dir: str, diff: SyncDiff, result: SyncResult) -> None:
496
+ log_path = wiki_dir / "log.md"
497
+ today = date.today().isoformat()
498
+ moved_str = (
499
+ ", ".join(
500
+ f"`{cls}` ({old} → {new})"
501
+ for cls, (old, new) in diff.moved_entities.items()
502
+ )
503
+ if diff.moved_entities
504
+ else "none"
505
+ )
506
+ entry = (
507
+ f"\n## {today}\n\n"
508
+ f"### feat: incremental sync\n"
509
+ f"- Source: `{src_dir}`\n"
510
+ f"- Pages created: {result.created}\n"
511
+ f"- Pages updated: {result.updated}\n"
512
+ f"- Pages skipped (unchanged): {result.skipped}\n"
513
+ f"- Pages deprecated: {result.deprecated}\n"
514
+ f"- Moved entities: {moved_str}\n"
515
+ )
516
+ if log_path.exists():
517
+ existing_log = read_md(log_path)
518
+ write_md(log_path, existing_log + entry)
519
+ else:
520
+ write_md(log_path, "# Architectural Log\n\nAppend-only chronological log.\n" + entry)
521
+ print(" APPEND log.md")