@caddis/cli 0.2.5 → 0.3.0

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 (54) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  34. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
  35. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
  40. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
  41. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  42. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  43. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  44. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  45. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  48. package/bundles/manifest.json +3 -3
  49. package/dist/claude-deepseek.js +544 -0
  50. package/dist/claude-glm.js +544 -0
  51. package/dist/claude-oss.js +544 -0
  52. package/dist/cli.js +375 -46
  53. package/package.json +5 -2
  54. package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
@@ -0,0 +1,438 @@
1
+ """Dream Memory — caddis's short-term, self-maintaining per-repo fact store (fann Phase 5a).
2
+
3
+ Ports the dedupe / reinforce / prune / conflict logic of fann-core's
4
+ ``src/memory/consolidator.ts`` into caddis's hooks-and-scripts world. This module is the
5
+ **pure consolidation engine** — the genuinely portable, fully-testable core. It owns the fact
6
+ schema and the four pure functions the design names (``merge`` / ``prune`` / ``conflicts`` /
7
+ ``rank_for_surfacing``), plus thin, fail-open filesystem glue to load/save the JSONL store.
8
+
9
+ Mental model (see ``.caddis/plans/dream-memory-design.md``): Dream Memory is *short-term working
10
+ memory* — automatic, structured facts that get **reinforced** when they recur and **decay** when
11
+ they don't. It complements (never replaces) the curated layers: ``relay.md`` (session handoff),
12
+ ``.caddis/kb/*.md`` (curated code docs), and the central ``.claude`` MEMORY.md (cross-repo
13
+ durable facts). A fact that proves durable is *promoted* up into a curated store and then allowed
14
+ to decay out here.
15
+
16
+ Design split mirrors ``check_doc_coverage.py``: a filesystem-free pure core (text/dicts in →
17
+ dicts out, unit-tested without a tree) and a small glue layer (``load_facts`` / ``save_facts`` /
18
+ the CLI) that does all I/O and **fails open & silent** — Dream Memory must never block or slow a
19
+ turn.
20
+
21
+ A fact (one JSON object per line in ``.caddis/memory.jsonl``)::
22
+
23
+ {
24
+ "kind": "failure-mode | rejected-approach | repo-fact | workflow-success",
25
+ "key": "pytest::import-error", # normalized dedup key (kind-scoped)
26
+ "summary": "pytest fails on a cold checkout — run `alembic upgrade` first",
27
+ "hitCount": 3,
28
+ "firstSeen": "2026-07-01T09:00:00Z",
29
+ "lastSeen": "2026-07-01T15:00:00Z",
30
+ "source": "auto | knowledge-transfer | manual",
31
+ "evidence": "tests/test_api.py" # optional, short
32
+ }
33
+
34
+ Fingerprint = ``kind + ":" + normalize(key)``. The file holds **consolidated** facts (not raw
35
+ events) — it self-compacts each cycle (``merge`` then ``prune``).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import json
42
+ import sys
43
+ from datetime import datetime, timedelta, timezone
44
+ from pathlib import Path
45
+
46
+ # --------------------------------------------------------------------------- #
47
+ # Schema constants. Tunables live here (a per-repo ``.caddis/config.toml``
48
+ # override is a deliberate fast-follow, matching check_doc_coverage / guard).
49
+ # --------------------------------------------------------------------------- #
50
+ KINDS: tuple[str, ...] = ("failure-mode", "rejected-approach", "repo-fact", "workflow-success")
51
+ SOURCES: tuple[str, ...] = ("auto", "knowledge-transfer", "manual")
52
+
53
+ # The "don't repeat this" kinds — weighted up when surfacing (they cost the most to relearn).
54
+ WEIGHTED_KINDS: frozenset[str] = frozenset({"failure-mode", "rejected-approach"})
55
+
56
+ PRUNE_AGE_DAYS = 14 # single-hit facts older than this decay out
57
+ MAX_FACTS = 200 # hard cap on the store; oldest-by-recency overflow drops first
58
+ SURFACE_LIMIT = 5 # SessionStart shows at most this many (context-economy-safe)
59
+
60
+ STORE_NAME = "memory.jsonl"
61
+ DEFAULT_STORE = f".caddis/{STORE_NAME}" # nominal path; resolve per-repo via store_path()
62
+
63
+ _REQUIRED_FIELDS = ("kind", "key", "summary", "hitCount", "firstSeen", "lastSeen", "source")
64
+ _EPOCH = datetime.min.replace(tzinfo=timezone.utc)
65
+
66
+
67
+ def store_path(root) -> Path:
68
+ """This repo's fact store, at ``.caddis/memory.jsonl``."""
69
+ try:
70
+ from claudster_config import artifact_read
71
+ return artifact_read(root, STORE_NAME)
72
+ except Exception:
73
+ return Path(root) / DEFAULT_STORE
74
+
75
+
76
+ # --------------------------------------------------------------------------- #
77
+ # Pure helpers (no filesystem) — the testable core.
78
+ # --------------------------------------------------------------------------- #
79
+ def normalize_key(value: str) -> str:
80
+ """Lowercase, trim, and collapse internal whitespace — the dedup-key canonical form."""
81
+ return " ".join((value or "").strip().lower().split())
82
+
83
+
84
+ def fingerprint(fact: dict) -> str:
85
+ """The dedup identity. Prefers an explicit ``fp`` (a hash of the FULL command, set at capture) so two
86
+ distinct commands that share a truncated 80-char head don't collapse into one inflated ``hitCount``;
87
+ falls back to ``kind:normalize(key)`` for facts written before ``fp`` existed (and for non-command facts)."""
88
+ fp = fact.get("fp")
89
+ if isinstance(fp, str) and fp:
90
+ return f"{fact.get('kind', '')}:{fp}"
91
+ basis = fact.get("key") or fact.get("summary") or ""
92
+ return f"{fact.get('kind', '')}:{normalize_key(basis)}"
93
+
94
+
95
+ def _parse_iso(value) -> datetime:
96
+ """Parse an ISO-8601 timestamp to an aware datetime; unparseable/empty → the epoch floor.
97
+
98
+ Tolerant by design (the store may be hand-edited): a bad timestamp must never crash a cycle,
99
+ it just sorts as oldest. Accepts a trailing ``Z`` (Python <3.11 ``fromisoformat`` rejects it).
100
+ """
101
+ if isinstance(value, datetime):
102
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
103
+ s = (value or "").strip()
104
+ if not s:
105
+ return _EPOCH
106
+ if s.endswith(("Z", "z")):
107
+ s = s[:-1] + "+00:00"
108
+ try:
109
+ dt = datetime.fromisoformat(s)
110
+ except ValueError:
111
+ return _EPOCH
112
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
113
+
114
+
115
+ def make_fact(
116
+ kind: str,
117
+ key: str,
118
+ summary: str,
119
+ observed_at: str,
120
+ *,
121
+ source: str = "auto",
122
+ evidence: str | None = None,
123
+ fp: str | None = None,
124
+ ) -> dict:
125
+ """Construct a well-formed single-hit fact (``hitCount=1``, first==last==observed).
126
+
127
+ ``fp`` (optional) is the full-command dedup fingerprint; when set it overrides the key-based
128
+ fingerprint so distinct commands sharing a truncated display key don't merge."""
129
+ fact = {
130
+ "kind": kind,
131
+ "key": key,
132
+ "summary": summary,
133
+ "hitCount": 1,
134
+ "firstSeen": observed_at,
135
+ "lastSeen": observed_at,
136
+ "source": source,
137
+ }
138
+ if evidence:
139
+ fact["evidence"] = evidence
140
+ if fp:
141
+ fact["fp"] = fp
142
+ return fact
143
+
144
+
145
+ def is_valid_fact(fact) -> bool:
146
+ """True if ``fact`` has every required field, a known ``kind``, and a positive int hitCount.
147
+
148
+ Used to reject malformed/hand-broken lines when loading and to validate capture candidates.
149
+ """
150
+ if not isinstance(fact, dict):
151
+ return False
152
+ if any(f not in fact for f in _REQUIRED_FIELDS):
153
+ return False
154
+ if fact["kind"] not in KINDS:
155
+ return False
156
+ hc = fact["hitCount"]
157
+ if not isinstance(hc, int) or isinstance(hc, bool) or hc < 1:
158
+ return False
159
+ return bool(str(fact["summary"]).strip())
160
+
161
+
162
+ def _hit(fact: dict) -> int:
163
+ try:
164
+ n = int(fact.get("hitCount", 1))
165
+ except (TypeError, ValueError):
166
+ return 1
167
+ return n if n >= 1 else 1
168
+
169
+
170
+ # --------------------------------------------------------------------------- #
171
+ # The four pure consolidation functions the design names. Filesystem-free.
172
+ # --------------------------------------------------------------------------- #
173
+ def merge(facts: list[dict]) -> list[dict]:
174
+ """Group by fingerprint into consolidated facts (insertion order of first appearance).
175
+
176
+ For each group: sum ``hitCount``; ``firstSeen`` = min, ``lastSeen`` = max; the **newest**
177
+ (latest ``lastSeen``) member's ``summary`` / ``source`` / ``evidence`` win. This is the
178
+ reinforce step — re-running it over (existing ⧺ new candidates) accretes hit counts. Each
179
+ returned fact is a fresh dict; inputs are never mutated.
180
+ """
181
+ merged: dict[str, dict] = {}
182
+ order: list[str] = []
183
+
184
+ for f in facts:
185
+ fp = fingerprint(f)
186
+ if fp not in merged:
187
+ first = f.get("firstSeen") or f.get("lastSeen") or ""
188
+ last = f.get("lastSeen") or f.get("firstSeen") or ""
189
+ g = dict(f)
190
+ g["hitCount"] = _hit(f)
191
+ g["firstSeen"] = first
192
+ g["lastSeen"] = last
193
+ merged[fp] = g
194
+ order.append(fp)
195
+ continue
196
+
197
+ g = merged[fp]
198
+ g["hitCount"] += _hit(f)
199
+
200
+ f_first = f.get("firstSeen") or f.get("lastSeen") or ""
201
+ if _parse_iso(f_first) < _parse_iso(g["firstSeen"]):
202
+ g["firstSeen"] = f_first
203
+
204
+ f_last = f.get("lastSeen") or f.get("firstSeen") or ""
205
+ # >= so a later-in-list tie counts as "newest wins" (last writer for same timestamp).
206
+ if _parse_iso(f_last) >= _parse_iso(g["lastSeen"]):
207
+ g["lastSeen"] = f_last
208
+ if f.get("summary"):
209
+ g["summary"] = f["summary"]
210
+ if f.get("source"):
211
+ g["source"] = f["source"]
212
+ if f.get("evidence"):
213
+ g["evidence"] = f["evidence"]
214
+
215
+ return [merged[fp] for fp in order]
216
+
217
+
218
+ def prune(facts: list[dict], now, *, max_age_days: int = PRUNE_AGE_DAYS, cap: int = MAX_FACTS) -> list[dict]:
219
+ """Decay then cap.
220
+
221
+ 1. Drop ``hitCount <= 1`` facts whose ``lastSeen`` is older than ``max_age_days`` (one-hit
222
+ noise fades on its own). Reinforced facts (hitCount >= 2) are never aged out.
223
+ 2. Keep the top ``cap`` by ``(hitCount desc, lastSeen desc)`` — overflow drops the
224
+ least-reinforced, oldest facts first.
225
+
226
+ ``now`` may be an ISO string or a datetime. Returns a new list; inputs are never mutated.
227
+ """
228
+ now_dt = _parse_iso(now)
229
+ cutoff = now_dt - timedelta(days=max_age_days)
230
+
231
+ kept = [
232
+ f for f in facts
233
+ if not (_hit(f) <= 1 and _parse_iso(f.get("lastSeen")) < cutoff)
234
+ ]
235
+ kept.sort(key=lambda f: (_hit(f), _parse_iso(f.get("lastSeen"))), reverse=True)
236
+ return kept[:cap]
237
+
238
+
239
+ def conflicts(facts: list[dict]) -> list[dict]:
240
+ """Flag fingerprints carrying materially different summaries — don't auto-resolve, surface.
241
+
242
+ "Materially different" = distinct after :func:`normalize_key`. Returns one entry per
243
+ conflicting fingerprint: ``{"kind", "key", "summaries": [...]}`` with the distinct summaries
244
+ in first-seen order. Intended to run over (existing ⧺ incoming) *before* merge collapses them.
245
+ """
246
+ by_fp: dict[str, list[dict]] = {}
247
+ for f in facts:
248
+ by_fp.setdefault(fingerprint(f), []).append(f)
249
+
250
+ out: list[dict] = []
251
+ for group in by_fp.values():
252
+ distinct: list[str] = []
253
+ seen_norm: set[str] = set()
254
+ for f in group:
255
+ summary = str(f.get("summary", ""))
256
+ norm = normalize_key(summary)
257
+ if norm and norm not in seen_norm:
258
+ seen_norm.add(norm)
259
+ distinct.append(summary)
260
+ if len(distinct) > 1:
261
+ out.append({
262
+ "kind": group[0].get("kind", ""),
263
+ "key": group[0].get("key", ""),
264
+ "summaries": distinct,
265
+ })
266
+ return out
267
+
268
+
269
+ def rank_for_surfacing(facts: list[dict], n: int = SURFACE_LIMIT, *, now=None) -> list[dict]:
270
+ """Top ``n`` facts to show at SessionStart — score ≈ ``weight × hitCount × recency``.
271
+
272
+ ``failure-mode`` / ``rejected-approach`` (the "don't repeat this" kinds) are weighted up. If
273
+ ``now`` is given, recency decays continuously with age; otherwise facts are ordered by
274
+ ``lastSeen`` directly (a now-free, deterministic proxy). Stable: ties break by ``lastSeen``
275
+ desc then fingerprint. Returns a new list; inputs are never mutated.
276
+ """
277
+ now_dt = _parse_iso(now) if now is not None else None
278
+
279
+ def score(f: dict) -> float:
280
+ weight = 2.0 if f.get("kind") in WEIGHTED_KINDS else 1.0
281
+ if now_dt is None:
282
+ return weight * _hit(f)
283
+ age_days = max(0.0, (now_dt - _parse_iso(f.get("lastSeen"))).total_seconds() / 86400.0)
284
+ recency = 1.0 / (1.0 + age_days) # 1.0 today, halves after a day, decays smoothly
285
+ return weight * _hit(f) * recency
286
+
287
+ ranked = sorted(
288
+ facts,
289
+ key=lambda f: (score(f), _parse_iso(f.get("lastSeen")), fingerprint(f)),
290
+ reverse=True,
291
+ )
292
+ return ranked[:n]
293
+
294
+
295
+ def consolidate(facts: list[dict], now, *, max_age_days: int = PRUNE_AGE_DAYS, cap: int = MAX_FACTS) -> list[dict]:
296
+ """One full cycle: ``merge`` (reinforce + dedup) then ``prune`` (decay + cap). Pure."""
297
+ return prune(merge(facts), now, max_age_days=max_age_days, cap=cap)
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # Filesystem glue — fail-open & silent. All I/O lives here, mirroring
302
+ # check_doc_coverage's pure-core / glue split. Never raises into a hook.
303
+ # --------------------------------------------------------------------------- #
304
+ def load_facts(path: Path) -> list[dict]:
305
+ """Read the JSONL store, skipping blank/malformed/invalid lines. Missing file → ``[]``.
306
+
307
+ Tolerant by design: a hand-edited or partially-written store must never crash a turn.
308
+ """
309
+ path = Path(path)
310
+ if not path.exists():
311
+ return []
312
+ facts: list[dict] = []
313
+ try:
314
+ text = path.read_text(encoding="utf-8", errors="replace")
315
+ except OSError:
316
+ return []
317
+ for line in text.splitlines():
318
+ line = line.strip()
319
+ if not line:
320
+ continue
321
+ try:
322
+ obj = json.loads(line)
323
+ except (json.JSONDecodeError, ValueError):
324
+ continue
325
+ if is_valid_fact(obj):
326
+ facts.append(obj)
327
+ return facts
328
+
329
+
330
+ def save_facts(path: Path, facts: list[dict]) -> bool:
331
+ """Write facts as JSONL (one compact object per line). Returns True on success, never raises.
332
+
333
+ Writes via a temp file + atomic replace so a crash mid-write can't corrupt the store.
334
+ """
335
+ path = Path(path)
336
+ try:
337
+ path.parent.mkdir(parents=True, exist_ok=True)
338
+ tmp = path.with_suffix(path.suffix + ".tmp")
339
+ lines = [json.dumps(f, ensure_ascii=False, sort_keys=True) for f in facts]
340
+ tmp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
341
+ tmp.replace(path)
342
+ return True
343
+ except OSError:
344
+ return False
345
+
346
+
347
+ def load_tunables(root) -> dict:
348
+ """Read ``[dream_memory]`` overrides from the repo's ``config.toml``; defaults otherwise.
349
+
350
+ Returns ``{"prune_age_days", "max_facts", "surface_limit"}``. Fail-open — a missing config, a
351
+ missing helper, or a bad value degrades to the baked-in default, never raising into a hook.
352
+ """
353
+ defaults = {"prune_age_days": PRUNE_AGE_DAYS, "max_facts": MAX_FACTS, "surface_limit": SURFACE_LIMIT}
354
+ try:
355
+ from claudster_config import get_int, load_config
356
+ cfg = load_config(root, "dream_memory")
357
+ return {
358
+ "prune_age_days": get_int(cfg, "prune_age_days", PRUNE_AGE_DAYS),
359
+ "max_facts": get_int(cfg, "max_facts", MAX_FACTS),
360
+ "surface_limit": get_int(cfg, "surface_limit", SURFACE_LIMIT),
361
+ }
362
+ except Exception:
363
+ return defaults
364
+
365
+
366
+ def _repo_root() -> Path:
367
+ """Git top-level if available, else cwd — location-independent (matches the caddis hooks)."""
368
+ try:
369
+ import subprocess
370
+ out = subprocess.run(
371
+ ["git", "rev-parse", "--show-toplevel"],
372
+ capture_output=True, text=True, timeout=5,
373
+ )
374
+ if out.returncode == 0 and out.stdout.strip():
375
+ return Path(out.stdout.strip())
376
+ except Exception:
377
+ pass
378
+ return Path.cwd()
379
+
380
+
381
+ def _format_surface(facts: list[dict]) -> list[str]:
382
+ """Render ranked facts as the ≤5 SessionStart lines (the surfacing hook reuses this in 5c)."""
383
+ lines: list[str] = []
384
+ for f in facts:
385
+ mark = "⚠ " if f.get("kind") in WEIGHTED_KINDS else " "
386
+ hits = _hit(f)
387
+ suffix = f" (×{hits})" if hits > 1 else ""
388
+ lines.append(f" {mark}{f.get('kind')}: {f.get('summary')}{suffix}")
389
+ return lines
390
+
391
+
392
+ def main() -> None:
393
+ if hasattr(sys.stdout, "reconfigure"):
394
+ sys.stdout.reconfigure(encoding="utf-8") # Windows cp1252 can't encode ⚠/× glyphs
395
+ parser = argparse.ArgumentParser(
396
+ description="Inspect / consolidate the Dream Memory fact store (.caddis/memory.jsonl).",
397
+ )
398
+ parser.add_argument(
399
+ "--consolidate",
400
+ action="store_true",
401
+ help="Run a merge+prune cycle and rewrite the store (no-op if it would not change).",
402
+ )
403
+ parser.add_argument(
404
+ "--now",
405
+ default=None,
406
+ help="ISO timestamp to use as 'now' for decay/ranking (default: current UTC).",
407
+ )
408
+ args = parser.parse_args()
409
+
410
+ now = args.now or datetime.now(timezone.utc).isoformat()
411
+ root = _repo_root()
412
+ store = store_path(root)
413
+ tune = load_tunables(root)
414
+ facts = load_facts(store)
415
+
416
+ if not facts:
417
+ print("[memory] no facts yet.")
418
+ return
419
+
420
+ if args.consolidate:
421
+ consolidated = consolidate(facts, now, max_age_days=tune["prune_age_days"], cap=tune["max_facts"])
422
+ save_facts(store, consolidated)
423
+ print(f"[memory] consolidated: {len(facts)} → {len(consolidated)} facts.")
424
+ facts = consolidated
425
+
426
+ top = rank_for_surfacing(facts, tune["surface_limit"], now=now)
427
+ print("[memory] reinforced facts for this repo (auto; fades if not seen):")
428
+ for line in _format_surface(top):
429
+ print(line)
430
+ issues = conflicts(facts)
431
+ if issues:
432
+ print(f"\n[memory] {len(issues)} conflicting fact(s) — review:")
433
+ for c in issues:
434
+ print(f" {c['kind']}: {c['key']} → {len(c['summaries'])} differing summaries")
435
+
436
+
437
+ if __name__ == "__main__":
438
+ main()