get-claudia 1.63.0 → 1.64.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to Claudia will be documented in this file.
4
4
 
5
+ ## 1.64.0 (2026-06-13)
6
+
7
+ ### Added
8
+
9
+ - **Status-only wrap for daemon scheduled jobs** (Proposal 11, E5). The seven background jobs (importance decay, pattern detection, consolidation, daily and weekly backups, vault sync, observation ingest) now each write `~/.claudia/loops/<job>_status.md` and check deterministic invariants (the backup jobs assert the backup file exists and is non-empty). It is status-only: a failed invariant is flagged but never halts the job, and a job that raises is recorded and then re-raised, so daemon behavior is unchanged. `memory.system_health` and the `/status` endpoint now surface each job's last verdict plus a flagged count. New module `claudia_memory/loops/job_wrapper.py` (6 tests); health surfacing has 2 tests; full daemon suite stays green (803 passed). This completes Proposal 11.
10
+
5
11
  ## 1.63.0 (2026-06-13)
6
12
 
7
13
  ### Added
@@ -152,6 +152,34 @@ def build_status_report(*, db=None) -> dict:
152
152
  except Exception:
153
153
  report["components"]["scheduler"] = "error"
154
154
 
155
+ # Loop status files (Proposal 11, E5): last verdict per wrapped daemon job.
156
+ # Purely observational; the overall status is not changed by a flagged job.
157
+ try:
158
+ from ..loops.job_wrapper import default_loops_dir
159
+ from ..loops.status import read_status
160
+
161
+ loops = []
162
+ loops_dir = default_loops_dir()
163
+ if loops_dir.exists():
164
+ for status_file in sorted(loops_dir.glob("*_status.md")):
165
+ try:
166
+ fields, _ = read_status(status_file)
167
+ except Exception:
168
+ continue
169
+ loops.append(
170
+ {
171
+ "job": fields.get("loop_id", status_file.stem.replace("_status", "")),
172
+ "verified": fields.get("verified"),
173
+ "verdict": fields.get("checker_verdict"),
174
+ "updated_at": fields.get("updated_at"),
175
+ }
176
+ )
177
+ report["loops"] = loops
178
+ report["loops_flagged"] = sum(1 for entry in loops if entry.get("verified") is False)
179
+ except Exception:
180
+ report["loops"] = []
181
+ report["loops_flagged"] = 0
182
+
155
183
  return report
156
184
 
157
185
 
@@ -23,10 +23,24 @@ from ..services.consolidate import (
23
23
  run_full_consolidation,
24
24
  )
25
25
  from ..services.vault_sync import run_vault_sync
26
+ from ..loops.job_wrapper import run_with_status
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
29
30
 
31
+ def _file_nonempty(path) -> "tuple[bool, str]":
32
+ """Invariant: a backup path must exist and be a non-empty file."""
33
+ try:
34
+ p = Path(path)
35
+ if not p.exists():
36
+ return False, f"backup file missing: {p}"
37
+ if p.stat().st_size == 0:
38
+ return False, f"backup file is empty: {p}"
39
+ return True, ""
40
+ except Exception as e: # noqa: BLE001
41
+ return False, f"could not stat backup: {e!r}"
42
+
43
+
30
44
  # Tools whose invocations are always relevant for Claudia's memory
31
45
  RELEVANT_TOOL_PREFIXES = {
32
46
  "gmail", "google_workspace", "slack", "telegram",
@@ -282,72 +296,99 @@ class MemoryScheduler:
282
296
  """Run importance decay daily"""
283
297
  try:
284
298
  logger.debug("Running daily decay")
285
- result = run_decay()
299
+ result = run_with_status(
300
+ "daily_decay",
301
+ run_decay,
302
+ invariants=[("completed", lambda r: (r is not None, "decay returned no result"))],
303
+ )
286
304
  logger.debug(f"Daily decay complete: {result}")
287
- except Exception as e:
305
+ except Exception:
288
306
  logger.exception("Error in daily decay")
289
307
 
290
308
  def _run_pattern_detection(self) -> None:
291
309
  """Run pattern detection"""
292
310
  try:
293
311
  logger.debug("Running pattern detection")
294
- patterns = detect_patterns()
312
+ patterns = run_with_status(
313
+ "pattern_detection",
314
+ detect_patterns,
315
+ invariants=[("is_list", lambda r: (isinstance(r, (list, tuple)), "patterns not a list"))],
316
+ )
295
317
  logger.info(f"Pattern detection complete: {len(patterns)} patterns detected")
296
- except Exception as e:
318
+ except Exception:
297
319
  logger.exception("Error in pattern detection")
298
320
 
299
321
  def _run_full_consolidation(self) -> None:
300
322
  """Run full overnight consolidation"""
301
323
  try:
302
324
  logger.info("Running full consolidation")
303
- result = run_full_consolidation()
325
+ result = run_with_status(
326
+ "full_consolidation",
327
+ run_full_consolidation,
328
+ invariants=[("completed", lambda r: (r is not None, "consolidation returned no result"))],
329
+ )
304
330
  logger.info(f"Full consolidation complete: {result}")
305
- except Exception as e:
331
+ except Exception:
306
332
  logger.exception("Error in full consolidation")
307
333
 
308
334
  def _run_daily_backup(self) -> None:
309
335
  """Create a labeled daily backup with 7-day retention."""
310
336
  try:
311
337
  from ..database import get_db
312
- backup_path = get_db().backup(label="daily")
338
+ backup_path = run_with_status(
339
+ "daily_backup",
340
+ lambda: get_db().backup(label="daily"),
341
+ invariants=[("backup_nonempty", lambda p: _file_nonempty(p))],
342
+ )
313
343
  logger.info(f"Daily backup created: {backup_path}")
314
- except Exception as e:
344
+ except Exception:
315
345
  logger.exception("Error in daily backup")
316
346
 
317
347
  def _run_weekly_backup(self) -> None:
318
348
  """Create a labeled weekly backup with 4-week retention."""
319
349
  try:
320
350
  from ..database import get_db
321
- backup_path = get_db().backup(label="weekly")
351
+ backup_path = run_with_status(
352
+ "weekly_backup",
353
+ lambda: get_db().backup(label="weekly"),
354
+ invariants=[("backup_nonempty", lambda p: _file_nonempty(p))],
355
+ )
322
356
  logger.info(f"Weekly backup created: {backup_path}")
323
- except Exception as e:
357
+ except Exception:
324
358
  logger.exception("Error in weekly backup")
325
359
 
326
360
  def _run_vault_sync(self) -> None:
327
361
  """Run Obsidian vault sync + canvas regeneration"""
328
362
  try:
329
- logger.info("[Safety-net full sync] Running after 4R Reweave inline in consolidation")
330
363
  logger.info("Running vault sync")
331
364
  from ..config import _project_id
332
365
  from ..services.vault_sync import get_vault_path
333
366
  from ..services.canvas_generator import CanvasGenerator
334
367
 
335
- result = run_vault_sync(project_id=_project_id)
336
- logger.info(f"Vault sync complete: {result}")
337
-
338
- # Regenerate canvases after sync
339
- vault_path = get_vault_path(_project_id)
340
- gen = CanvasGenerator(vault_path)
341
- canvas_result = gen.generate_all()
342
- logger.info(f"Canvas regeneration complete: {canvas_result}")
343
- except Exception as e:
368
+ def _sync_and_canvas():
369
+ result = run_vault_sync(project_id=_project_id)
370
+ logger.info(f"Vault sync complete: {result}")
371
+ vault_path = get_vault_path(_project_id)
372
+ canvas_result = CanvasGenerator(vault_path).generate_all()
373
+ logger.info(f"Canvas regeneration complete: {canvas_result}")
374
+ return result
375
+
376
+ run_with_status(
377
+ "vault_sync",
378
+ _sync_and_canvas,
379
+ invariants=[("completed", lambda r: (r is not None, "vault sync returned no result"))],
380
+ )
381
+ except Exception:
344
382
  logger.exception("Error in vault sync")
345
383
 
346
384
  def _run_observation_ingest(self) -> None:
347
385
  """Ingest observations from PostToolUse hook captures."""
348
386
  try:
349
387
  from ..database import get_db
350
- _ingest_observations(get_db(), self.config)
388
+ run_with_status(
389
+ "observation_ingest",
390
+ lambda: _ingest_observations(get_db(), self.config),
391
+ )
351
392
  except Exception as e:
352
393
  logger.debug(f"Error in observation ingestion: {e}")
353
394
 
@@ -0,0 +1,106 @@
1
+ """Status-only job wrapper for daemon scheduled jobs (Proposal 11, E5).
2
+
3
+ Wraps a scheduled job so it writes a status file and flags invariant failures,
4
+ without ever changing the job's behavior. This is the "status-only" form of the
5
+ daemon wrap: a failed invariant is recorded but does NOT halt the job, and a job
6
+ that raises is recorded and then re-raised so the daemon's existing error
7
+ handling is preserved.
8
+
9
+ Each invariant is a ``(name, check)`` pair where ``check(result)`` returns
10
+ ``(ok: bool, detail: str)``. A check may inspect the job's return value or
11
+ external state (a backup file on disk, an entity count); it is deterministic, not
12
+ an LLM (see Proposal 11 Decision D2: the daemon has no agent context).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Iterable
20
+
21
+ from claudia_memory.loops.status import write_status
22
+
23
+ Invariant = tuple[str, Callable[[Any], "tuple[bool, str]"]]
24
+
25
+
26
+ def default_loops_dir() -> Path:
27
+ """Where daemon job status files live."""
28
+ return Path.home() / ".claudia" / "loops"
29
+
30
+
31
+ def _now_iso() -> str:
32
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
33
+
34
+
35
+ def run_with_status(
36
+ job_id: str,
37
+ fn: Callable[[], Any],
38
+ invariants: Iterable[Invariant] = (),
39
+ status_dir: "str | Path | None" = None,
40
+ now: "str | None" = None,
41
+ ) -> Any:
42
+ """Run ``fn``, check ``invariants``, write ``<job_id>_status.md``, return result.
43
+
44
+ Status-only contract:
45
+ - A failed invariant flags the run (``verified: false``) but does not halt it;
46
+ the job's result is still returned.
47
+ - A job that raises is recorded as unverified and then re-raised, so the
48
+ daemon's existing error handling is unchanged.
49
+ """
50
+ base = Path(status_dir) if status_dir is not None else default_loops_dir()
51
+
52
+ error: BaseException | None = None
53
+ result: Any = None
54
+ try:
55
+ result = fn()
56
+ except Exception as e: # noqa: BLE001 - recorded below, then re-raised
57
+ error = e
58
+
59
+ checks: list[tuple[str, bool, str]] = []
60
+ if error is None:
61
+ for name, check in invariants:
62
+ try:
63
+ ok, detail = check(result)
64
+ except Exception as e: # noqa: BLE001 - a raising check is a failed check
65
+ ok, detail = False, f"invariant raised: {e!r}"
66
+ checks.append((name, bool(ok), str(detail)))
67
+
68
+ verified = error is None and all(ok for _, ok, _ in checks)
69
+ summary, body = _format_verdict(job_id, error, checks)
70
+
71
+ write_status(
72
+ base / f"{job_id}_status.md",
73
+ {
74
+ "loop_id": job_id,
75
+ "verified": verified,
76
+ "checker_verdict": summary,
77
+ "next_action": "none" if verified else "review flagged job run",
78
+ "updated_at": now or _now_iso(),
79
+ },
80
+ body=body,
81
+ )
82
+
83
+ if error is not None:
84
+ raise error
85
+ return result
86
+
87
+
88
+ def _format_verdict(job_id: str, error: "BaseException | None", checks) -> "tuple[str, str]":
89
+ if error is not None:
90
+ summary = f"job raised {type(error).__name__}: {error}"
91
+ return summary, f"# Loop status: {job_id}\n\n{summary}"
92
+
93
+ failed = [(name, detail) for name, ok, detail in checks if not ok]
94
+ if not checks:
95
+ summary = "ran; no invariants defined"
96
+ elif not failed:
97
+ summary = f"all {len(checks)} invariant(s) held"
98
+ else:
99
+ names = ", ".join(name for name, _ in failed)
100
+ summary = f"{len(failed)} of {len(checks)} invariant(s) failed: {names}"
101
+
102
+ lines = [f"# Loop status: {job_id}", "", summary]
103
+ if failed:
104
+ lines.append("")
105
+ lines.extend(f"- {name}: {detail}" for name, detail in failed)
106
+ return summary, "\n".join(lines)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-claudia",
3
- "version": "1.63.0",
3
+ "version": "1.64.0",
4
4
  "description": "An AI assistant who learns how you work.",
5
5
  "keywords": [
6
6
  "claudia",
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.63.0",
3
- "generated": "2026-06-14T02:49:59.295Z",
2
+ "version": "1.64.0",
3
+ "generated": "2026-06-14T03:04:35.117Z",
4
4
  "algorithm": "sha256",
5
5
  "files": {
6
6
  ".claude/rules/claudia-principles.md": "939e9720421628e7f2e4c8dfbaa4aeb9c1e18e8c6a5379cd6b772a6835b812e5",