get-claudia 1.65.0 → 1.66.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,29 @@
2
2
 
3
3
  All notable changes to Claudia will be documented in this file.
4
4
 
5
+ ## 1.66.0 (2026-07-09)
6
+
7
+ ### Added
8
+
9
+ - **Commitment resolver: expired and stale auto-archive** (Proposal 12, P2). A new nightly `resolve_commitments()` phase inside `run_full_consolidation` (Phase 1c) retires commitments that have gone quiet: `expired` when a deadline passed more than the grace window ago and the item has not been touched since, `stale` when a deadline-less commitment is old, unreferenced, and low importance. It is archive-never-delete (Prop 12 D5): rows keep `lifecycle_tier='archived'` and stay fully queryable, with `metadata.resolution` and `metadata.resolved_at` recording why and when, so the briefing can disclose the batch and you can restore any of them by setting `lifecycle_tier='active'`. Sacred and invalidated commitments are never touched. Gentle, tunable thresholds via new config knobs (`commitment_grace_days=14`, `commitment_stale_days=60`, `commitment_stale_importance_ceiling=0.5`, `commitment_resolver_enabled=true`).
10
+ - **Salience-ranked session briefing** (Proposal 12, P3). The briefing's commitment section no longer dumps a raw "N active" count. It shows the top 3 by soonest deadline then importance, each with its due date, followed by one honest summary line ("+N more active; M auto-archived recently, say 'restore' to correct") so an auto-archived batch is always disclosed (Prop 12 locked decision 4). The top proactive prediction is now gated by importance (new `prediction_surface_threshold=0.6` knob) so weak hunches stay quiet at session start.
11
+ - **Honest memory-degradation disclosure** (Proposal 12, P3). When local embedding intelligence (Ollama) is unreachable, the briefing's first line says so plainly: recall still works, but semantic extraction and ambient capture are paused. The check is warm by the time the briefing runs (the SessionStart health check populates it first) and fails open, so it never blocks startup or false-alarms.
12
+ - **Daemon self-heal at session start.** The SessionStart health-check hook now probes the memory daemon and, when it is down on macOS with a LaunchAgent present, attempts exactly one automatic restart (a launchd reload mirroring the installer), then re-probes: on recovery it says so, otherwise it injects concrete, stepwise fix guidance (`claudia system-health`, reinstall via `npx get-claudia .`, the Windows deps known issue, and the context-files fallback). A hook never becomes a process manager, so every non-macOS or no-plist path only advises. Fail-open and hard-bounded so it never blocks or crashes session start. So a fresh install (or a crashed daemon) surfaces the problem and often fixes itself instead of silently doing nothing.
13
+
14
+ ### Fixed
15
+
16
+ - **Briefing counted archived and invalidated commitments** (issue #67 class). The commitment count filtered only `invalidated_at IS NULL`, so once the new resolver archived a commitment it would still inflate the "active" number. The briefing now routes through a single `_top_commitments` helper whose active filter excludes both archived and invalidated rows.
17
+
18
+ ### Changed
19
+
20
+ - **`auto-research` worked example** (Proposal 11, E2/B4). The skill now includes a short dry-run transcript: a board-update iteration showing baseline scoring, keep/revert decisions, a `contested` iteration where Claudia self-scores 9.5 but the independent Checker holds it to 8.4, the resulting `research_status.md`, and the hand-off. This closes the last open sub-tranche of Proposal 11.
21
+ - **Retroactive credit: automated memory lifecycle transitions.** The active to cooling to archived lifecycle transitions (`run_lifecycle_transitions`, Phase 1b of nightly consolidation) shipped in v1.65.0 inside `run_full_consolidation` but were not listed in that release's changelog entry. Noting it here for the record; Proposal 12 P2 (the commitment resolver above) builds directly on that machinery.
22
+
23
+ ### Stats
24
+
25
+ - 34 new tests (12 commitment resolver, 11 briefing salience/degradation, 11 daemon self-heal). Daemon suite 851 passed, 12 skipped; hooks suite 57 passed, 29 subtests. 0 regressions.
26
+ - No schema migration. One template hook changed (`session-health-check.py`, daemon self-heal), added per the release requirement.
27
+
5
28
  ## 1.65.0 (2026-06-15)
6
29
 
7
30
  ### Added
@@ -104,6 +104,15 @@ class MemoryConfig:
104
104
  # Lifecycle & Sacred memory settings
105
105
  cooling_threshold_days: int = 60 # Days without access before memory enters 'cooling'
106
106
  archive_threshold_days: int = 180 # Days in cooling + low importance before archiving
107
+
108
+ # Commitment resolution (Proposal 12 P2): gentle, reversible auto-archive
109
+ commitment_resolver_enabled: bool = True # Toggle the nightly commitment resolver
110
+ commitment_grace_days: int = 14 # Grace after a deadline passes before it counts as expired
111
+ commitment_stale_days: int = 60 # Age (no deadline) + unreferenced before a commitment goes stale
112
+ commitment_stale_importance_ceiling: float = 0.5 # Only stale-archive commitments below this importance
113
+
114
+ # Proactive surfacing (Proposal 12 P3)
115
+ prediction_surface_threshold: float = 0.6 # Min prediction priority before it surfaces in the brief
107
116
  enable_auto_sacred: bool = True # Auto-detect sacred facts for close-circle entities
108
117
  close_circle_keywords: list = field(default_factory=lambda: [
109
118
  "close friend", "bestie", "family", "inner circle", "best friend",
@@ -230,6 +239,16 @@ class MemoryConfig:
230
239
  config.cooling_threshold_days = data["cooling_threshold_days"]
231
240
  if "archive_threshold_days" in data:
232
241
  config.archive_threshold_days = data["archive_threshold_days"]
242
+ if "commitment_resolver_enabled" in data:
243
+ config.commitment_resolver_enabled = data["commitment_resolver_enabled"]
244
+ if "commitment_grace_days" in data:
245
+ config.commitment_grace_days = data["commitment_grace_days"]
246
+ if "commitment_stale_days" in data:
247
+ config.commitment_stale_days = data["commitment_stale_days"]
248
+ if "commitment_stale_importance_ceiling" in data:
249
+ config.commitment_stale_importance_ceiling = data["commitment_stale_importance_ceiling"]
250
+ if "prediction_surface_threshold" in data:
251
+ config.prediction_surface_threshold = data["prediction_surface_threshold"]
233
252
  if "enable_auto_sacred" in data:
234
253
  config.enable_auto_sacred = data["enable_auto_sacred"]
235
254
  if "close_circle_keywords" in data:
@@ -335,6 +354,18 @@ class MemoryConfig:
335
354
  if self.archive_threshold_days < self.cooling_threshold_days:
336
355
  logger.warning(f"archive_threshold_days must be >= cooling_threshold_days, using {self.cooling_threshold_days * 3}")
337
356
  self.archive_threshold_days = self.cooling_threshold_days * 3
357
+ if self.commitment_grace_days < 0:
358
+ logger.warning(f"commitment_grace_days={self.commitment_grace_days} below minimum, using 14")
359
+ self.commitment_grace_days = 14
360
+ if self.commitment_stale_days < 1:
361
+ logger.warning(f"commitment_stale_days={self.commitment_stale_days} below minimum, using 60")
362
+ self.commitment_stale_days = 60
363
+ if not (0.0 <= self.commitment_stale_importance_ceiling <= 1.0):
364
+ logger.warning(f"commitment_stale_importance_ceiling={self.commitment_stale_importance_ceiling} out of range [0,1], using default 0.5")
365
+ self.commitment_stale_importance_ceiling = 0.5
366
+ if not (0.0 <= self.prediction_surface_threshold <= 1.0):
367
+ logger.warning(f"prediction_surface_threshold={self.prediction_surface_threshold} out of range [0,1], using default 0.6")
368
+ self.prediction_surface_threshold = 0.6
338
369
  if self.context_builder_token_budget < 500:
339
370
  logger.warning(f"context_builder_token_budget={self.context_builder_token_budget} too small, using 2000")
340
371
  self.context_builder_token_budget = 2000
@@ -389,6 +420,11 @@ class MemoryConfig:
389
420
  "backup_weekly_retention": self.backup_weekly_retention,
390
421
  "cooling_threshold_days": self.cooling_threshold_days,
391
422
  "archive_threshold_days": self.archive_threshold_days,
423
+ "commitment_resolver_enabled": self.commitment_resolver_enabled,
424
+ "commitment_grace_days": self.commitment_grace_days,
425
+ "commitment_stale_days": self.commitment_stale_days,
426
+ "commitment_stale_importance_ceiling": self.commitment_stale_importance_ceiling,
427
+ "prediction_surface_threshold": self.prediction_surface_threshold,
392
428
  "enable_auto_sacred": self.enable_auto_sacred,
393
429
  "enable_chain_verification": self.enable_chain_verification,
394
430
  "context_builder_token_budget": self.context_builder_token_budget,
@@ -3249,6 +3249,185 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
3249
3249
  )
3250
3250
 
3251
3251
 
3252
+ def _top_commitments(db, n: int = 3) -> dict:
3253
+ """Salience-ranked active commitments for the session briefing (Prop 12 P3).
3254
+
3255
+ 'Active' excludes invalidated and archived rows (the #67 class): once the
3256
+ resolver archives a commitment it must never reappear in the brief. Ranks by
3257
+ deadline (soonest first, no-deadline last) then importance, and reports how
3258
+ many were auto-archived in the last 7 days so the brief can disclose the
3259
+ batch (Prop 12 locked decision 4).
3260
+
3261
+ Returns {"active_count": int, "top": [ {content, deadline_at, importance} ],
3262
+ "recently_archived_count": int}.
3263
+ """
3264
+ from datetime import datetime, timedelta
3265
+
3266
+ active_where = (
3267
+ "type = 'commitment' AND importance > 0.1 AND invalidated_at IS NULL "
3268
+ "AND (lifecycle_tier IS NULL OR lifecycle_tier != 'archived')"
3269
+ )
3270
+
3271
+ active_count = 0
3272
+ top = []
3273
+ recently_archived = 0
3274
+
3275
+ try:
3276
+ count_row = db.execute(
3277
+ f"SELECT COUNT(*) as cnt FROM memories WHERE {active_where}",
3278
+ fetch=True,
3279
+ )
3280
+ active_count = count_row[0]["cnt"] if count_row else 0
3281
+ except Exception as e:
3282
+ logger.debug(f"Briefing active commitment count failed: {e}")
3283
+
3284
+ try:
3285
+ rows = db.execute(
3286
+ f"""
3287
+ SELECT content, deadline_at, importance FROM memories
3288
+ WHERE {active_where}
3289
+ ORDER BY (deadline_at IS NULL), deadline_at ASC, importance DESC
3290
+ LIMIT ?
3291
+ """,
3292
+ (n,),
3293
+ fetch=True,
3294
+ ) or []
3295
+ top = [
3296
+ {
3297
+ "content": r["content"],
3298
+ "deadline_at": r["deadline_at"],
3299
+ "importance": r["importance"],
3300
+ }
3301
+ for r in rows
3302
+ ]
3303
+ except Exception as e:
3304
+ logger.debug(f"Briefing top commitments failed: {e}")
3305
+
3306
+ try:
3307
+ cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
3308
+ arch_row = db.execute(
3309
+ """
3310
+ SELECT COUNT(*) as cnt FROM memories
3311
+ WHERE type = 'commitment'
3312
+ AND json_valid(metadata)
3313
+ AND json_extract(metadata, '$.resolution') IS NOT NULL
3314
+ AND json_extract(metadata, '$.resolved_at') >= ?
3315
+ """,
3316
+ (cutoff,),
3317
+ fetch=True,
3318
+ )
3319
+ recently_archived = arch_row[0]["cnt"] if arch_row else 0
3320
+ except Exception as e:
3321
+ logger.debug(f"Briefing recent auto-archive count failed: {e}")
3322
+
3323
+ return {
3324
+ "active_count": active_count,
3325
+ "top": top,
3326
+ "recently_archived_count": recently_archived,
3327
+ }
3328
+
3329
+
3330
+ def _render_commitment_lines(commitments: dict) -> list:
3331
+ """Render the ranked commitment section for the briefing (Prop 12 D6).
3332
+
3333
+ A top-3 bulleted list (soonest deadline first), then one demoted summary line
3334
+ disclosing how many more are active and how many were auto-archived recently
3335
+ with a hint to restore (locked decision 4). Never a bare "N active" count.
3336
+ """
3337
+ lines = []
3338
+ top = commitments.get("top", [])
3339
+ active_count = commitments.get("active_count", 0)
3340
+ recently_archived = commitments.get("recently_archived_count", 0)
3341
+
3342
+ if not top:
3343
+ # No active items; still disclose a recent auto-archive batch if any.
3344
+ if recently_archived > 0:
3345
+ lines.append(
3346
+ f'**Commitments:** none active ({recently_archived} auto-archived '
3347
+ f'recently, say "restore" to correct)'
3348
+ )
3349
+ return lines
3350
+
3351
+ lines.append("**Commitments:**")
3352
+ for c in top:
3353
+ snippet = (c.get("content") or "").strip()[:80]
3354
+ deadline = c.get("deadline_at")
3355
+ due = f"due {str(deadline)[:10]}" if deadline else "no deadline"
3356
+ lines.append(f"- {snippet} ({due})")
3357
+
3358
+ summary_bits = []
3359
+ more = active_count - len(top)
3360
+ if more > 0:
3361
+ summary_bits.append(f"+{more} more active")
3362
+ if recently_archived > 0:
3363
+ summary_bits.append(
3364
+ f'{recently_archived} auto-archived recently, say "restore" to correct'
3365
+ )
3366
+ if summary_bits:
3367
+ lines.append(f"({'; '.join(summary_bits)})")
3368
+
3369
+ return lines
3370
+
3371
+
3372
+ def _top_prediction(db, threshold: float):
3373
+ """Return the single most salient un-shown prediction, or None if the top one
3374
+ is below the surface threshold (Prop 12 P3 interruptibility gate).
3375
+
3376
+ Gates on `priority` (the predictions table's salience field). No surfaced-history
3377
+ field beyond is_shown exists, so the gate is importance-only, no migration.
3378
+ """
3379
+ try:
3380
+ rows = db.execute(
3381
+ """
3382
+ SELECT content, prediction_type, priority FROM predictions
3383
+ WHERE expires_at > datetime('now') AND is_shown = 0
3384
+ ORDER BY priority DESC
3385
+ LIMIT 1
3386
+ """,
3387
+ fetch=True,
3388
+ )
3389
+ if not rows:
3390
+ return None
3391
+ row = rows[0]
3392
+ if row["priority"] is None or row["priority"] < threshold:
3393
+ return None
3394
+ return {
3395
+ "content": row["content"],
3396
+ "prediction_type": row["prediction_type"],
3397
+ "priority": row["priority"],
3398
+ }
3399
+ except Exception as e:
3400
+ logger.debug(f"Briefing top prediction failed: {e}")
3401
+ return None
3402
+
3403
+
3404
+ def _embeddings_available() -> bool:
3405
+ """Whether local embedding intelligence (Ollama) is up.
3406
+
3407
+ Delegates to the embedding service's is_available_sync(), which returns
3408
+ instantly once availability is known. The SessionStart health check calls
3409
+ the same method before the briefing is built, so it is warm in the normal
3410
+ flow and never blocks here. Fails open on error so a transient hiccup never
3411
+ cries wolf.
3412
+ """
3413
+ try:
3414
+ return bool(get_embedding_service().is_available_sync())
3415
+ except Exception as e:
3416
+ logger.debug(f"Embedding availability check failed: {e}")
3417
+ return True
3418
+
3419
+
3420
+ def _memory_degraded_line(available: bool):
3421
+ """The honest degradation disclosure (Prop 12). Returned as the briefing's
3422
+ first line when local memory intelligence is down; None when healthy."""
3423
+ if available:
3424
+ return None
3425
+ return (
3426
+ "⚠️ Memory intelligence degraded: Ollama unreachable. "
3427
+ "Recall works; semantic extraction and ambient capture are paused."
3428
+ )
3429
+
3430
+
3252
3431
  def _build_briefing() -> str:
3253
3432
  """
3254
3433
  Build a compact session briefing (~500 tokens).
@@ -3331,25 +3510,11 @@ def _build_briefing() -> str:
3331
3510
  except Exception as e:
3332
3511
  logger.debug(f"Briefing consolidation check failed: {e}")
3333
3512
 
3334
- # 1. Active commitments count + stale count
3513
+ # 1. Active commitments: salience-ranked top-3 + honest summary (Prop 12 P3).
3514
+ # Active excludes archived (resolver output) and invalidated rows (#67 class).
3335
3515
  try:
3336
- total_row = db.execute(
3337
- "SELECT COUNT(*) as cnt FROM memories WHERE type = 'commitment' AND importance > 0.1 AND invalidated_at IS NULL",
3338
- fetch=True,
3339
- )
3340
- total_commitments = total_row[0]["cnt"] if total_row else 0
3341
-
3342
- stale_cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
3343
- stale_row = db.execute(
3344
- "SELECT COUNT(*) as cnt FROM memories WHERE type = 'commitment' AND importance > 0.1 AND invalidated_at IS NULL AND created_at < ?",
3345
- (stale_cutoff,),
3346
- fetch=True,
3347
- )
3348
- stale_commitments = stale_row[0]["cnt"] if stale_row else 0
3349
-
3350
- if total_commitments > 0:
3351
- stale_note = f" ({stale_commitments} older than 7d)" if stale_commitments else ""
3352
- lines.append(f"**Commitments:** {total_commitments} active{stale_note}")
3516
+ commitments = _top_commitments(db, n=3)
3517
+ lines.extend(_render_commitment_lines(commitments))
3353
3518
  except Exception as e:
3354
3519
  logger.debug(f"Briefing commitments failed: {e}")
3355
3520
 
@@ -3384,19 +3549,12 @@ def _build_briefing() -> str:
3384
3549
  except Exception as e:
3385
3550
  logger.debug(f"Briefing unread failed: {e}")
3386
3551
 
3387
- # 4. Top prediction (1 line)
3552
+ # 4. Top prediction (1 line), gated by importance (Prop 12 P3 interruptibility)
3388
3553
  try:
3389
- pred_row = db.execute(
3390
- """
3391
- SELECT content, prediction_type FROM predictions
3392
- WHERE expires_at > datetime('now') AND is_shown = 0
3393
- ORDER BY priority DESC
3394
- LIMIT 1
3395
- """,
3396
- fetch=True,
3397
- )
3398
- if pred_row:
3399
- p = pred_row[0]
3554
+ from ..config import get_config as _get_config
3555
+ threshold = _get_config().prediction_surface_threshold
3556
+ p = _top_prediction(db, threshold)
3557
+ if p:
3400
3558
  lines.append(f"**Top prediction:** [{p['prediction_type']}] {p['content'][:100]}")
3401
3559
  except Exception as e:
3402
3560
  logger.debug(f"Briefing prediction failed: {e}")
@@ -3461,6 +3619,15 @@ def _build_briefing() -> str:
3461
3619
  if len(lines) <= 1:
3462
3620
  lines.append("No context available yet. This appears to be a fresh workspace.")
3463
3621
 
3622
+ # Honest degradation disclosure goes first (Prop 12). Prepend last so it does
3623
+ # not affect the fresh-workspace check above.
3624
+ try:
3625
+ degraded = _memory_degraded_line(_embeddings_available())
3626
+ if degraded:
3627
+ lines.insert(0, degraded)
3628
+ except Exception as e:
3629
+ logger.debug(f"Briefing degradation disclosure failed: {e}")
3630
+
3464
3631
  return "\n".join(lines)
3465
3632
 
3466
3633
 
@@ -2707,6 +2707,94 @@ class ConsolidateService:
2707
2707
 
2708
2708
  return {"cooled": cooled, "archived": archived}
2709
2709
 
2710
+ def resolve_commitments(self) -> dict:
2711
+ """Archive commitments that expired or went stale. Never delete (Prop 12 D5).
2712
+
2713
+ expired: deadline passed more than the grace window ago and not recently
2714
+ accessed (a recent access means it is still live in conversation).
2715
+ stale: no deadline, old, unreferenced, and low importance.
2716
+
2717
+ Sacred and invalidated rows are never touched. metadata.resolution records
2718
+ why and metadata.resolved_at records when, so the briefing can disclose the
2719
+ batch and the user can correct it (restore lifecycle_tier='active').
2720
+
2721
+ Counts use SELECT changes() (like _surge_approaching_deadlines): this db.execute
2722
+ wrapper returns None, so the cursor.rowcount idiom would always report 0.
2723
+ """
2724
+ config = self.config
2725
+ if not getattr(config, "commitment_resolver_enabled", True):
2726
+ return {"expired": 0, "stale": 0}
2727
+
2728
+ now = datetime.utcnow()
2729
+ now_iso = now.isoformat()
2730
+ grace_cutoff = (now - timedelta(days=config.commitment_grace_days)).isoformat()
2731
+ stale_cutoff = (now - timedelta(days=config.commitment_stale_days)).isoformat()
2732
+ importance_ceiling = config.commitment_stale_importance_ceiling
2733
+
2734
+ # Expired: deadline passed the grace window, not recently accessed.
2735
+ expired = 0
2736
+ try:
2737
+ with self.db.transaction():
2738
+ self.db.execute(
2739
+ """
2740
+ UPDATE memories
2741
+ SET lifecycle_tier = 'archived',
2742
+ archived_at = ?,
2743
+ updated_at = ?,
2744
+ metadata = json_set(
2745
+ CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END,
2746
+ '$.resolution', 'expired',
2747
+ '$.resolved_at', ?
2748
+ )
2749
+ WHERE type = 'commitment'
2750
+ AND invalidated_at IS NULL
2751
+ AND (lifecycle_tier IS NULL OR lifecycle_tier NOT IN ('sacred', 'archived'))
2752
+ AND deadline_at IS NOT NULL
2753
+ AND deadline_at < ?
2754
+ AND (last_accessed_at IS NULL OR last_accessed_at < ?)
2755
+ """,
2756
+ (now_iso, now_iso, now_iso, grace_cutoff, grace_cutoff),
2757
+ )
2758
+ res = self.db.execute("SELECT changes()", fetch=True)
2759
+ expired = res[0][0] if res else 0
2760
+ except Exception as e:
2761
+ logger.debug(f"Commitment expiry resolution error: {e}")
2762
+
2763
+ # Stale: no deadline, old, unreferenced, low importance.
2764
+ stale = 0
2765
+ try:
2766
+ with self.db.transaction():
2767
+ self.db.execute(
2768
+ """
2769
+ UPDATE memories
2770
+ SET lifecycle_tier = 'archived',
2771
+ archived_at = ?,
2772
+ updated_at = ?,
2773
+ metadata = json_set(
2774
+ CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END,
2775
+ '$.resolution', 'stale',
2776
+ '$.resolved_at', ?
2777
+ )
2778
+ WHERE type = 'commitment'
2779
+ AND invalidated_at IS NULL
2780
+ AND (lifecycle_tier IS NULL OR lifecycle_tier NOT IN ('sacred', 'archived'))
2781
+ AND deadline_at IS NULL
2782
+ AND created_at < ?
2783
+ AND (last_accessed_at IS NULL OR last_accessed_at < ?)
2784
+ AND importance < ?
2785
+ """,
2786
+ (now_iso, now_iso, now_iso, stale_cutoff, stale_cutoff, importance_ceiling),
2787
+ )
2788
+ res = self.db.execute("SELECT changes()", fetch=True)
2789
+ stale = res[0][0] if res else 0
2790
+ except Exception as e:
2791
+ logger.debug(f"Commitment stale resolution error: {e}")
2792
+
2793
+ if expired > 0 or stale > 0:
2794
+ logger.info(f"Commitment resolution: {expired} expired, {stale} stale (archived)")
2795
+
2796
+ return {"expired": expired, "stale": stale}
2797
+
2710
2798
  def detect_auto_sacred(self) -> int:
2711
2799
  """Auto-promote memories about close-circle entities that match sacred keywords."""
2712
2800
  config = self.config
@@ -2840,6 +2928,13 @@ class ConsolidateService:
2840
2928
  logger.warning(f"Lifecycle phase failed: {e}")
2841
2929
  results["lifecycle"] = {"error": str(e)}
2842
2930
 
2931
+ # Phase 1c: Commitment resolution (expired/stale auto-archive, Prop 12 P2)
2932
+ try:
2933
+ results["commitments"] = self.resolve_commitments()
2934
+ except Exception as e:
2935
+ logger.warning(f"Commitment resolution phase failed: {e}")
2936
+ results["commitments"] = {"error": str(e)}
2937
+
2843
2938
  # [4R: Reflect]
2844
2939
  # Phase 2: Merging (modifies memory content)
2845
2940
  try:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-claudia",
3
- "version": "1.65.0",
3
+ "version": "1.66.0",
4
4
  "description": "An AI assistant who learns how you work.",
5
5
  "keywords": [
6
6
  "claudia",
@@ -9,6 +9,8 @@ import json
9
9
  import os
10
10
  import shutil
11
11
  import subprocess
12
+ import sys
13
+ import time
12
14
  import urllib.request
13
15
  from pathlib import Path
14
16
 
@@ -24,6 +26,88 @@ def _fetch_briefing():
24
26
  return None
25
27
 
26
28
 
29
+ # Daemon self-heal at session start.
30
+ # A fresh install (or a crashed/killed daemon) can leave the memory daemon down,
31
+ # in which case the in-session memory tools silently do nothing. This probes the
32
+ # daemon and, on macOS with a LaunchAgent present, attempts exactly one automatic
33
+ # restart; every other down path only advises. A hook must never become a process
34
+ # manager, so process spawning is limited to a single launchd reload that mirrors
35
+ # the installer (bin/index.js). Fail-open and hard-bounded: it must never block or
36
+ # crash session start.
37
+
38
+ DAEMON_HEALTH_URL = "http://localhost:3848/health"
39
+ LAUNCHD_LABEL = "com.claudia.memory"
40
+ LAUNCHD_PLIST = Path.home() / "Library" / "LaunchAgents" / "com.claudia.memory.plist"
41
+
42
+
43
+ def _daemon_up(timeout: float = 2.0) -> bool:
44
+ """True if the memory daemon answers its /health endpoint. Fail-closed."""
45
+ try:
46
+ req = urllib.request.Request(DAEMON_HEALTH_URL)
47
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
48
+ return getattr(resp, "status", 200) == 200
49
+ except Exception:
50
+ return False
51
+
52
+
53
+ def _is_macos() -> bool:
54
+ return sys.platform == "darwin"
55
+
56
+
57
+ def _launchd_restart() -> None:
58
+ """One launchd reload: unload then load the plist (mirrors bin/index.js).
59
+ Bounded 5s per call, fail-open, never raises."""
60
+ for action in ("unload", "load"):
61
+ try:
62
+ subprocess.run(
63
+ ["launchctl", action, str(LAUNCHD_PLIST)],
64
+ capture_output=True, timeout=5,
65
+ )
66
+ except Exception:
67
+ pass
68
+
69
+
70
+ def _daemon_fix_guidance() -> str:
71
+ """Concrete, stepwise fix guidance when the daemon is down and could not be
72
+ auto-restarted, so the persona can walk the user through it."""
73
+ return (
74
+ "Memory daemon is not running and I could not auto-restart it. To fix: "
75
+ "(1) run `claudia system-health` to diagnose; "
76
+ "(2) on macOS, re-run `npx get-claudia .` from your Claudia folder to reinstall "
77
+ "the LaunchAgent, then restart this session; "
78
+ "(3) on Windows, this is known issue #37 (the daemon's Python dependencies may "
79
+ "never have installed) -- re-run the installer and check that ~/.claudia/daemon/ "
80
+ "has a venv. "
81
+ "Meanwhile I will work from context/ files only (no semantic search or pattern "
82
+ "detection), and I will say so rather than imply memory is live."
83
+ )
84
+
85
+
86
+ def _ensure_daemon() -> str:
87
+ """Ensure the memory daemon is up at session start.
88
+
89
+ Returns a message to surface (a recovery note or fix guidance), or '' when the
90
+ daemon is healthy. Fail-open and hard-bounded so it never blocks or crashes
91
+ session start.
92
+ """
93
+ try:
94
+ if _daemon_up(timeout=2.0):
95
+ return ""
96
+ if _is_macos() and LAUNCHD_PLIST.exists():
97
+ _launchd_restart()
98
+ time.sleep(2) # give launchd a moment to bring the daemon up
99
+ if _daemon_up(timeout=2.0):
100
+ return (
101
+ "Memory daemon was down at session start; I restarted it "
102
+ "automatically. Memory is available."
103
+ )
104
+ return _daemon_fix_guidance()
105
+ # Non-macOS, or macOS with no LaunchAgent plist: advise only, never spawn.
106
+ return _daemon_fix_guidance()
107
+ except Exception:
108
+ return ""
109
+
110
+
27
111
  def _recent_sessions_summary(max_days: int = 3) -> str:
28
112
  """Return a compact recap of the last N days of session summaries.
29
113
 
@@ -322,10 +406,13 @@ def check_health():
322
406
  parts.append("Embeddings unavailable.")
323
407
 
324
408
  summary = " ".join(parts) if parts else "System ready."
409
+ daemon_note = _ensure_daemon()
325
410
  briefing = _fetch_briefing()
326
411
  recent = _recent_sessions_summary()
327
412
 
328
413
  sections = [f"Memory system healthy. {summary}"]
414
+ if daemon_note:
415
+ sections.append("--- Memory Daemon ---\n" + daemon_note)
329
416
  if recent:
330
417
  sections.append("--- Recent Sessions ---\n" + recent)
331
418
  if briefing:
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.65.0",
3
- "generated": "2026-06-16T03:58:36.245Z",
2
+ "version": "1.66.0",
3
+ "generated": "2026-07-09T20:07:26.062Z",
4
4
  "algorithm": "sha256",
5
5
  "files": {
6
6
  ".claude/rules/claudia-principles.md": "939e9720421628e7f2e4c8dfbaa4aeb9c1e18e8c6a5379cd6b772a6835b812e5",
@@ -21,7 +21,7 @@
21
21
  ".claude/skills/archetypes/executive.md": "0d43c5c2c6315f5a4d6d36150778b55229cc922e0491a80af54824d87ac52e82",
22
22
  ".claude/skills/archetypes/founder.md": "a5bf3f22439c7c5f554c13966899a4af4de80f90c8f6a25eb62be1c68de6d54f",
23
23
  ".claude/skills/archetypes/solo.md": "cc26332b96394775e62a0f1a06f32093be6147bb75fa3783aa271e554d323c6b",
24
- ".claude/skills/auto-research/SKILL.md": "2f7f8a7d0731813921094b0117026693314f0f59e8d9b52637bd3e775a2e95f8",
24
+ ".claude/skills/auto-research/SKILL.md": "ca02992657324bd48c84c00168062903ce7fda303479185b1aeb62a90a97c46d",
25
25
  ".claude/skills/auto-research/references/program-template.md": "850457de96bde5ad65d24f1018509da94dd31fec9ec45c15087031f470a28dbc",
26
26
  ".claude/skills/auto-research/references/safety-rules.md": "ae036270e86949ed0f9bface582f6038276adafd3f40a29aa733698111a26683",
27
27
  ".claude/skills/brain/SKILL.md": "b87dd93c85923c676292bea67f7a0580ee71eea2bea05426ea6eda951a140d5c",
@@ -151,6 +151,56 @@ proposes one fix, and proves it on the exact failing input before adopting it.
151
151
  See `.claude/skills/_loop/repair.md`. It is capped at 2 repair attempts and never
152
152
  auto-edits shipped briefs (those changes go to a human approval gate).
153
153
 
154
+ ## Worked example (dry run)
155
+
156
+ A short run of "tighten this board update", to show the loop and the independent
157
+ Checker in practice. The artifact is `artifact.md`; the rubric is the board
158
+ update rubric below. Budget: 20 iterations.
159
+
160
+ ```
161
+ iter 0: score 6.1 (baseline) checker: solid update, but the lede buries the one number that matters; the ask is split across two sentences
162
+ iter 1: score 7.0 (kept) maker: moved the revenue number into the opening line | checker: lede now leads with the number; ask still split
163
+ iter 2: score 7.0 (reverted) maker: cut the second paragraph | checker: lost the context the board needs for the ask; net neutral
164
+ iter 3: score 7.8 (kept) maker: merged the ask into one sentence | checker: ask is now one clear line; tone matches prior updates
165
+ iter 4: score 8.4 (kept) [contested] maker self-score 9.5 | checker: tighter, but "significantly" is doing unearned work in the headline
166
+ iter 5: score 8.4 (reverted) maker: swapped the closing line | checker: weaker call to action than iter 4; revert
167
+ iter 6: plateau no gain for the last 3 iterations; remaining changes are cosmetic
168
+ ```
169
+
170
+ The loop stops at iteration 6 on plateau. `research_status.md` at that point:
171
+
172
+ ```markdown
173
+ ---
174
+ loop_id: tighten-board-update-20260613
175
+ iteration: 6
176
+ verified: true
177
+ score: 8.4
178
+ budget_remaining: 14
179
+ last_input: board update draft v4
180
+ maker_proposal: swapped the closing line (reverted)
181
+ checker_verdict: best version is iter 4; remaining changes are cosmetic
182
+ next_action: hand off iter 4 to the user
183
+ updated_at: 2026-06-13T15:02:00Z
184
+ ---
185
+
186
+ # Loop status: tighten-board-update
187
+
188
+ Ratcheted 6.1 to 8.4 over 4 kept iterations. One contested iteration (iter 4):
189
+ Claudia self-scored 9.5, the Checker scored 8.4 and flagged an unearned
190
+ intensifier. The Checker's score governed. Plateaued at iter 6.
191
+ ```
192
+
193
+ Hand-off to the user: "Started at 6.1, ended at 8.4 over 6 iterations. The three
194
+ biggest jumps were moving the revenue number into the lede (iter 1), collapsing
195
+ the ask into one sentence (iter 3), and the tone pass (iter 4). One iteration was
196
+ contested: I scored iter 4 a 9.5, the Checker held it to 8.4 because the headline
197
+ leaned on 'significantly' with no number behind it. I kept the Checker's call.
198
+ Want iter 4 back at the original location?"
199
+
200
+ That contested iteration is the whole point of the split. Without an independent
201
+ Checker, iter 4 ships at the inflated 9.5 self-score. With it, the unearned
202
+ intensifier gets caught and the honest 8.4 governs.
203
+
154
204
  ## Safety rules (mandatory, follow without exception)
155
205
 
156
206
  1. **Workspace-only edits.** The loop never writes to a file outside `~/.claudia/auto-research/<task-id>/`. The user's original file at `/Users/.../wherever.md` is untouched until the user explicitly approves the final hand-off.