daimon-briefing 0.3.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.
daimon_briefing/cli.py ADDED
@@ -0,0 +1,1119 @@
1
+ """Dogfood CLI — works WITHOUT hermes, on a plain text/markdown transcript.
2
+
3
+ daimon serialize <transcript-file> transcript -> checkpoint (+latest)
4
+ daimon brief latest checkpoint -> briefing on stdout
5
+ daimon recall <query...> FTS5 search over local + team
6
+ checkpoint history (derived index)
7
+ daimon status [--project DIR] [--json]
8
+ checkpoint presence/age + last
9
+ serialize outcome from the log
10
+ daimon heal re-serialize the most recent
11
+ FAILED session if safe (#26)
12
+ daimon configure [--backend ...] detect the resolved LLM backend
13
+ and fill gaps in ~/.daimon/env
14
+ daimon write-checkpoint [--project DIR] [--source S]
15
+ store a checkpoint read as JSON on
16
+ stdin (the #23 introspection path)
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ import time
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+
28
+ from . import anchor, briefing, carry, config, configure, harvest, llm, recall, render, serializer, store, teamsync, transcript
29
+ from . import __version__
30
+
31
+ # Module-level seam so tests can inject a fake LLM client.
32
+ _chat = llm.chat
33
+
34
+
35
+ def _prompt(question: str) -> str:
36
+ """Raw interactive prompt — a tiny seam so tests can monkeypatch input."""
37
+ return input(question).strip()
38
+
39
+
40
+ def _resolve_project(arg) -> str:
41
+ """Project dir for routing: explicit --project, else DAIMON_PROJECT_DIR, else cwd.
42
+
43
+ Resolved to an absolute path BEFORE the store slugs it: the store derives
44
+ slugs from absolute paths, so a relative "." (or a bare manual re-run) would
45
+ otherwise never match a written checkpoint's slug.
46
+
47
+ Then normalized to the git toplevel (#74) so a subdir session shares the ONE
48
+ repo bucket; resolve_project_root returns the input unchanged when it is not a
49
+ git repo, so the absolute-path fallback above still holds.
50
+ """
51
+ project = arg or config.project_dir() or os.getcwd()
52
+ resolved = str(Path(project).expanduser().resolve())
53
+ return config.resolve_project_root(resolved)
54
+
55
+
56
+ def _append_serialize_log(line: str) -> None:
57
+ """Append a result line to serialize.log so manual/CLI serializes are
58
+ visible to `status`, not only hook-spawned ones (FR #27). Best-effort:
59
+ logging must never break a serialize."""
60
+ try:
61
+ log_dir = config.log_dir()
62
+ log_dir.mkdir(parents=True, exist_ok=True)
63
+ with (log_dir / "serialize.log").open("a", encoding="utf-8") as f:
64
+ f.write(line + "\n")
65
+ except OSError:
66
+ pass
67
+
68
+
69
+ def _append_retry_log(session_id: str, prior: str) -> None:
70
+ """Mark a #26 heal retry in serialize.log BEFORE re-serializing. The line is
71
+ a TIMESTAMPED spawn-style marker (matching the hook spawn-line stamp format)
72
+ so `status` surfaces it AND the dedup check can find it later — one retry per
73
+ session, ever. Best-effort: never break a heal."""
74
+ try:
75
+ log_dir = config.log_dir()
76
+ log_dir.mkdir(parents=True, exist_ok=True)
77
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
78
+ with (log_dir / "serialize.log").open("a", encoding="utf-8") as f:
79
+ f.write(f"{stamp} session-start: retry serialize for {session_id} (prior: {prior})\n")
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ def _run_serialize(transcript_path: Path, project: str | None) -> int:
85
+ """Serialize one transcript to a checkpoint routed to `project` (used AS-IS;
86
+ None => global pointer only, NO cwd fallback). The caller decides routing —
87
+ this never calls _resolve_project, so `heal` can route to the FAILED
88
+ session's project rather than the heal-time cwd.
89
+
90
+ Every result line is built once into `msg`, printed, AND logged via
91
+ _append_serialize_log — the logged string is byte-identical to the printed
92
+ one so _RESULT_OK_RE / _RESULT_ERR_RE (raw, no timestamp) still match it.
93
+ (No "(superseded by newer checkpoint)" hint here: result lines carry no
94
+ timestamp to compare against a checkpoint mtime — out of scope, FR #27.)
95
+ Returns the rc."""
96
+ path = transcript_path
97
+ try:
98
+ messages = transcript.from_file(path)
99
+ except FileNotFoundError:
100
+ msg = f"error: transcript not found: {path}"
101
+ print(msg, file=sys.stderr)
102
+ _append_serialize_log(msg)
103
+ return 2
104
+
105
+ # Pre-flight missing credentials so the error names them before any LLM work
106
+ # (a conflated message cost a live debugging round-trip — see PR #12 fallout).
107
+ if _chat is llm.chat and not config.llm_api_key():
108
+ msg = "error: no LLM API key — set DAIMON_LLM_API_KEY (env or ~/.daimon/env)"
109
+ print(msg, file=sys.stderr)
110
+ _append_serialize_log(msg)
111
+ return 1
112
+ if _chat is llm.chat and not config.llm_model():
113
+ msg = "error: no LLM model — set DAIMON_LLM_MODEL (env or ~/.daimon/env)"
114
+ print(msg, file=sys.stderr)
115
+ _append_serialize_log(msg)
116
+ return 1
117
+
118
+ session_id = path.stem
119
+ # Elapsed time lands in serialize.log — checkpoint generation runs 4-25 min
120
+ # in production and was invisible before this.
121
+ start = time.monotonic()
122
+ try:
123
+ checkpoint = serializer.serialize_strict(session_id, messages, chat=_chat)
124
+ except serializer.TooShortError as exc:
125
+ msg = f"skipped serialize for {session_id}: {exc}"
126
+ print(msg)
127
+ _append_serialize_log(msg)
128
+ return 0
129
+ except serializer.SerializeError as exc:
130
+ elapsed = int(time.monotonic() - start)
131
+ msg = f"error: {exc} (transcript: {path}) after {elapsed}s"
132
+ print(msg, file=sys.stderr)
133
+ _append_serialize_log(msg)
134
+ return 1
135
+ # `created` = when the SESSION ended, not when this write happens (#123).
136
+ # Stamped here — not left to store's setdefault-now — so a heal/re-serialize
137
+ # of an old transcript carries its true age and store's pointer guard can
138
+ # keep it from stealing `latest` from a newer session.
139
+ checkpoint["created"] = _session_end_stamp(path)
140
+ if config.carry_enabled():
141
+ # Deterministic carry (#33 Phase 2): fold the previous checkpoint's
142
+ # unresolved items in BEFORE the write rotates it away. Clock = this
143
+ # checkpoint's own stamp (scar: never default to wall clock when a
144
+ # stamp exists), wall time only as fallback for stampless paths.
145
+ # Advisory feature — a raise here must never cost us the checkpoint
146
+ # itself (a briefing missing carried items is strictly better than
147
+ # no briefing at all; same idiom as harvest.run's swallow below).
148
+ try:
149
+ prev = store.read_latest(project)
150
+ now = store._created_epoch(checkpoint.get("created")) or time.time()
151
+ checkpoint = carry.merge(checkpoint, prev, now,
152
+ floor=config.carry_floor(),
153
+ cap=config.carry_max())
154
+ except Exception: # keep the unmerged checkpoint, proceed to write
155
+ pass
156
+ out = store.write_checkpoint(session_id, checkpoint, project_dir=project)
157
+ elapsed = int(time.monotonic() - start)
158
+ msg = f"wrote checkpoint: {out} (took {elapsed}s)"
159
+ print(msg)
160
+ _append_serialize_log(msg)
161
+ # Opt-in scar-candidate harvest (#100), mirroring the hermes host wiring
162
+ # (hooks.on_session_end). It runs AFTER the result line is printed AND logged,
163
+ # and ANY failure is swallowed here — the harvest must never change this
164
+ # function's rc nor disturb the byte-identical print/log result contract above.
165
+ # harvest.run itself no-ops on project=None and on repos with no .scars/, so the
166
+ # call site stays a thin gate; cli has no logger, so best-effort is silent (the
167
+ # same idiom as _append_serialize_log's swallow).
168
+ if config.scar_harvest_enabled():
169
+ try:
170
+ harvest.run(messages, project_root=project, session_id=session_id)
171
+ except Exception: # a broken harvest must not fail the serialize
172
+ pass
173
+ return 0
174
+
175
+
176
+ def _session_end_stamp(path) -> str:
177
+ """When the session in `path` ended, in checkpoint `created` format (#123):
178
+ the transcript's last message timestamp, falling back to the file mtime
179
+ (markdown/plain transcripts carry no per-row stamps), then to now."""
180
+ stamp = transcript.last_timestamp(path)
181
+ if stamp:
182
+ return stamp
183
+ try:
184
+ mtime = Path(path).stat().st_mtime
185
+ except OSError:
186
+ mtime = time.time()
187
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(mtime))
188
+
189
+
190
+ def _cmd_serialize(args) -> int:
191
+ return _run_serialize(Path(args.transcript), _resolve_project(args.project))
192
+
193
+
194
+ def _cmd_write_checkpoint(args) -> int:
195
+ """Write a checkpoint supplied as JSON on stdin (the #23 introspection path).
196
+
197
+ The live session emits its own cognitive state per the schema and pipes it
198
+ here; we validate (reusing serializer.validate — the same bar the hook's
199
+ reconstruction must clear), stamp `source`, and route through the normal
200
+ store (project + global + per-session, with rotation). Provisional by design:
201
+ a later SessionEnd reconstruction supersedes it and rotation keeps this as a
202
+ prev pointer — so it never has to be verbatim-perfect to be useful."""
203
+ raw = sys.stdin.read()
204
+ try:
205
+ checkpoint = json.loads(raw)
206
+ except json.JSONDecodeError as exc:
207
+ print(f"error: invalid checkpoint JSON on stdin: {exc}", file=sys.stderr)
208
+ return 1
209
+ if not isinstance(checkpoint, dict) or not str(checkpoint.get("session_id", "")).strip():
210
+ print("error: checkpoint must be a JSON object with a non-empty session_id", file=sys.stderr)
211
+ return 1
212
+ if not serializer.validate(checkpoint):
213
+ print(
214
+ "error: checkpoint failed schema validation — need session_id, "
215
+ "working_context (active_topic + open_questions/recent_decisions lists) "
216
+ "and epistemic_snapshot (strong_beliefs/uncertainties lists), each item "
217
+ "trust-tagged",
218
+ file=sys.stderr,
219
+ )
220
+ return 1
221
+ checkpoint["source"] = args.source # provenance: introspection vs reconstruction
222
+ session_id = str(checkpoint["session_id"])
223
+ out = store.write_checkpoint(session_id, checkpoint, project_dir=_resolve_project(args.project))
224
+ print(f"wrote checkpoint: {out} (source: {args.source})")
225
+ return 0
226
+
227
+
228
+ def _cmd_anchor(args) -> int:
229
+ project = _resolve_project(args.project)
230
+ a = anchor.resolve(project, args.file, args.symbol)
231
+ if a is None:
232
+ print(f"error: could not resolve {args.file}::{args.symbol} under {project}",
233
+ file=sys.stderr)
234
+ return 1
235
+ if not args.attach:
236
+ print(json.dumps(a, indent=2))
237
+ return 0
238
+ # --attach (#102): patch the anchor into the latest checkpoint's single
239
+ # matching cognitive item and re-write through the NORMAL store path, so
240
+ # rotation + stamping apply — the attached state becomes latest, the
241
+ # pre-attach state is retained as prev-1.
242
+ checkpoint = store.read_latest(project_dir=project)
243
+ if checkpoint is None:
244
+ print(f"error: no checkpoint found for {project} — nothing to attach to",
245
+ file=sys.stderr)
246
+ return 1
247
+ needle = args.attach.lower()
248
+ matches = [
249
+ item for item in anchor._all_items(checkpoint)
250
+ if isinstance(item, dict) and needle in str(item.get("text", "")).lower()
251
+ ]
252
+ if not matches:
253
+ print(f"error: no cognitive item text contains {args.attach!r} "
254
+ "in the latest checkpoint", file=sys.stderr)
255
+ return 1
256
+ if len(matches) > 1:
257
+ print(f"error: {len(matches)} items match {args.attach!r} — "
258
+ "narrow the match:", file=sys.stderr)
259
+ for item in matches:
260
+ print(f" - {item.get('text')}", file=sys.stderr)
261
+ return 1
262
+ session_id = str(checkpoint.get("session_id", "")).strip()
263
+ if not session_id:
264
+ print("error: latest checkpoint has no session_id — cannot re-write",
265
+ file=sys.stderr)
266
+ return 1
267
+ item = matches[0]
268
+ item["anchored_to"] = a
269
+ store.write_checkpoint(session_id, checkpoint, project_dir=project)
270
+ print(f"attached {a['qualified_name']} to: {item.get('text')}")
271
+ return 0
272
+
273
+
274
+ def _team_briefings(project) -> list:
275
+ """Per-teammate briefing sections for `brief --team`, EXCLUDING the current
276
+ author. Returns [(author, sections), ...] newest-first, or [] when the team dir
277
+ is empty (nothing was ever mirrored). Reuses briefing.build so the #77 decision
278
+ cap applies to teammates identically. Self is matched by slug — the same dir
279
+ identity read_team fans in on."""
280
+ # project_slug munging, matching _dual_write_team's dir identity — _safe_name
281
+ # would re-introduce the "a/b" == "a_b" collision on the self-match.
282
+ self_slug = store.project_slug(config.author())
283
+ out = []
284
+ for author, checkpoint in store.read_team(project_dir=project):
285
+ if store.project_slug(author) == self_slug:
286
+ continue # never surface your own state as a teammate
287
+ b = briefing.build(checkpoint)
288
+ if b is None:
289
+ continue # nothing worth surfacing for this teammate
290
+ out.append((author, b))
291
+ return out
292
+
293
+
294
+ def _cmd_brief(args) -> int:
295
+ # Route like status/serialize: --project, else DAIMON_PROJECT_DIR, else cwd.
296
+ # read_latest still falls back to the global pointer if the project has none.
297
+ project = _resolve_project(args.project)
298
+ checkpoint = store.read_latest(project_dir=project)
299
+ # NOTE: drift is checked against the resolved project root. If read_latest fell
300
+ # back to the GLOBAL pointer (another project's checkpoint), its anchor file paths
301
+ # are relative to a different root and may report spurious "hard" drift. Acceptable
302
+ # for v1 (degrades safely); origin-project gating is future work (#60 follow-up).
303
+ drift = anchor.drifted(checkpoint, project) if checkpoint else []
304
+ # --team (#111): fan in teammates for THIS project. Empty team → None → the
305
+ # renderer emits no Teammates section, byte-identical to a non-team briefing.
306
+ teammates = _team_briefings(project) if getattr(args, "team", False) else None
307
+ render.render_brief(checkpoint, drift=drift, teammates=teammates)
308
+ return 0
309
+
310
+
311
+ # ---- recall: FTS search over local + team checkpoint history (#112) ----
312
+
313
+
314
+ def _cmd_recall(args) -> int:
315
+ """Lexical search over the derived recall index. The index is disposable —
316
+ recall.search auto-(re)builds it — so the only hard failure surfaced here is
317
+ an FTS5-less sqlite3 (rc 1, named); everything else degrades to no matches."""
318
+ query = " ".join(args.query)
319
+ project = _resolve_project(args.project)
320
+ try:
321
+ results = recall.search(query, project_dir=project,
322
+ all_projects=args.all_projects, limit=args.limit)
323
+ except recall.RecallError as exc:
324
+ print(f"error: {exc}", file=sys.stderr)
325
+ return 1
326
+ if args.json:
327
+ print(json.dumps(results, indent=2, ensure_ascii=False))
328
+ return 0
329
+ if not results:
330
+ print("no matches")
331
+ return 0
332
+ now = time.time()
333
+ for r in results:
334
+ age = _format_age(now - r["created"]) if r.get("created") else "?"
335
+ superseded = f" [superseded by {r['superseded_by']}]" if r.get("superseded_by") else ""
336
+ trust = r.get("trust") or "untagged"
337
+ print(f"[{r['author']}] [{trust}] [{r['kind']}] {r['text']} "
338
+ f"({r['session_id']}, {age} ago){superseded}")
339
+ return 0
340
+
341
+
342
+ # ---- recall-inject: the UserPromptSubmit hook backend (#125) ----
343
+
344
+ _SEEN_PRUNE_SECONDS = 7 * 86400 # cooldown files for week-old sessions are dead
345
+
346
+
347
+ def _seen_path(session: str):
348
+ """Cooldown-state file for one session, or None when the id is unusable
349
+ (empty, or path-hostile — the id becomes a filename)."""
350
+ if not session or "/" in session or "\\" in session or ".." in session:
351
+ return None
352
+ return config.recall_seen_dir() / f"{session}.json"
353
+
354
+
355
+ def _load_seen(path) -> set:
356
+ try:
357
+ raw = json.loads(path.read_text(encoding="utf-8"))
358
+ return {str(s) for s in raw} if isinstance(raw, list) else set()
359
+ except (OSError, json.JSONDecodeError):
360
+ return set()
361
+
362
+
363
+ def _save_seen(path, seen: set) -> None:
364
+ try:
365
+ path.parent.mkdir(parents=True, exist_ok=True)
366
+ path.write_text(json.dumps(sorted(seen)), encoding="utf-8")
367
+ # Opportunistic prune: cooldown state for long-dead sessions.
368
+ cutoff = time.time() - _SEEN_PRUNE_SECONDS
369
+ for p in path.parent.iterdir():
370
+ try:
371
+ if p.is_file() and p.stat().st_mtime < cutoff:
372
+ p.unlink()
373
+ except OSError:
374
+ pass
375
+ except OSError:
376
+ pass # cooldown is best-effort; losing it means one extra suggestion
377
+
378
+
379
+ def _suggest_line(r: dict, terms, now: float) -> str:
380
+ """One compact, attributed, trust-preserving injection line (#125)."""
381
+ age = _format_age(now - r["created"]) if r.get("created") else "?"
382
+ trust = r.get("trust") or "untagged"
383
+ text = r["text"] if len(r["text"]) <= 160 else r["text"][:157] + "..."
384
+ superseded = " (superseded — newer checkpoint exists)" if r.get("superseded_by") else ""
385
+ more = " ".join(terms[:3])
386
+ return (f"daimon recall: prior work — {r['kind']} from {r['session_id']} "
387
+ f"({age} ago): \"{text}\" [{trust}]{superseded}. "
388
+ f"More: daimon recall \"{more}\"")
389
+
390
+
391
+ def _cmd_recall_inject(args) -> int:
392
+ """Print 0-2 'you worked on this before' lines for the prompt on stdin, or
393
+ nothing. rc 0 ALWAYS — this sits on the user's per-prompt critical path and
394
+ a suggestion is never worth blocking a prompt (fail-open, like the hooks)."""
395
+ try:
396
+ prompt = sys.stdin.read()
397
+ project = _resolve_project(args.project)
398
+ session = str(args.session or "")
399
+ # Never re-suggest what the SessionStart briefing already carried: the
400
+ # project's latest and the global latest are briefed by definition.
401
+ exclude = set()
402
+ for cp in (store.read_latest(project), store.read_latest()):
403
+ sid = (cp or {}).get("session_id")
404
+ if sid:
405
+ exclude.add(str(sid))
406
+ seen_file = _seen_path(session)
407
+ seen = _load_seen(seen_file) if seen_file else set()
408
+ matches = recall.suggest(prompt, project_dir=project,
409
+ current_session=session,
410
+ exclude_sessions=exclude | seen)
411
+ if not matches:
412
+ return 0
413
+ now = time.time()
414
+ terms = recall.salient_terms(prompt)
415
+ for m in matches:
416
+ print(_suggest_line(m, terms, now))
417
+ if seen_file:
418
+ _save_seen(seen_file, seen | {str(m["session_id"]) for m in matches})
419
+ except Exception: # noqa: BLE001 — see docstring: fail-open, always rc 0
420
+ pass
421
+ return 0
422
+
423
+
424
+ # ---- status: "did my ending checkpoint get generated?" without grepping logs ----
425
+
426
+ # Hook spawn line: `<iso-stamp> <hook>: spawned serialize for <id> (...)`,
427
+ # where <hook> is `session-end` (Claude), `codex-stop` (Codex), or
428
+ # `gemini-session-end` (Gemini — must be listed BEFORE a bare `session-end`
429
+ # would substring-match it; the alternation is exact so order only matters for
430
+ # readability). The #26 heal retry marker (`<iso> session-start: retry
431
+ # serialize for <id> (...)`) is also a spawn for status purposes, so both the
432
+ # host and the verb are alternations. A new host adapter MUST add its prefix
433
+ # here or its serializes are invisible to status/hung detection/heal.
434
+ _SPAWN_RE = re.compile(
435
+ r"^(\S+) (?:gemini-session-end|session-end|codex-stop|session-start): "
436
+ r"(?:spawned|retry) serialize for (\S+)"
437
+ )
438
+ # Child stdout/stderr land in the log RAW (no timestamp): the serialize
439
+ # success/error lines printed by _cmd_serialize above.
440
+ _RESULT_OK_RE = re.compile(r"^wrote checkpoint: .+ \(took (\d+)s\)")
441
+ _RESULT_ERR_RE = re.compile(r"^error: .*?(?: after (\d+)s)?$")
442
+
443
+
444
+ def _format_age(seconds) -> str:
445
+ """Coarse human age: 59 -> '59s', 61 -> '1m', 7200 -> '2h', 432000 -> '5d'."""
446
+ seconds = max(0, int(seconds))
447
+ if seconds < 60:
448
+ return f"{seconds}s"
449
+ if seconds < 3600:
450
+ return f"{seconds // 60}m"
451
+ if seconds < 86400:
452
+ return f"{seconds // 3600}h"
453
+ return f"{seconds // 86400}d"
454
+
455
+
456
+ # Shared with store (single copy; hook/daimon-session-brief.py keeps its own
457
+ # stdlib-only twin — see the docstring in store._created_epoch).
458
+ _created_epoch = store._created_epoch
459
+
460
+
461
+ def _checkpoint_info(path, now) -> dict:
462
+ """Existence/identity/age of a latest-pointer file. Never raises. Age prefers
463
+ the written `created` stamp (which survives pointer rotation) and falls back to
464
+ file mtime for legacy checkpoints (#93)."""
465
+ if path is None or not path.exists():
466
+ return {"exists": False, "path": str(path) if path else None}
467
+ created = format_version = None
468
+ try:
469
+ data = json.loads(path.read_text(encoding="utf-8"))
470
+ session_id = data.get("session_id")
471
+ created = data.get("created")
472
+ format_version = data.get("format_version")
473
+ except (OSError, json.JSONDecodeError):
474
+ session_id = None # torn/foreign file: still report presence + age
475
+ epoch = _created_epoch(created)
476
+ age = int(now - (epoch if epoch is not None else path.stat().st_mtime))
477
+ return {
478
+ "exists": True,
479
+ "session_id": session_id,
480
+ "format_version": format_version,
481
+ "age_seconds": age,
482
+ "age": _format_age(age),
483
+ "path": str(path),
484
+ }
485
+
486
+
487
+ def _status_health(proj, glob, outstanding, siblings, *, now) -> dict:
488
+ """Objective health verdict for `status`. Pure — `now` is injected. Warns only
489
+ on data-driven signals: a NEWER phantom-child bucket (the #74 split), a missing
490
+ project checkpoint, or outstanding serialize failures. No age thresholds."""
491
+ warnings: list[str] = []
492
+
493
+ proj_mtime = (now - proj["age_seconds"]) if proj.get("exists") else None
494
+ newer = [
495
+ s for s in siblings
496
+ if proj_mtime is None or s["mtime"] > proj_mtime
497
+ ]
498
+ for s in sorted(newer, key=lambda s: s["mtime"], reverse=True):
499
+ sid = s["session_id"] or "unknown"
500
+ age = _format_age(int(now - s["mtime"]))
501
+ warnings.append(
502
+ f"split: related bucket '{s['slug']}' has newer work "
503
+ f"(session {sid}, {age} ago) — a subdir session may have split your history"
504
+ )
505
+
506
+ if not proj.get("exists"):
507
+ warnings.append(
508
+ "no checkpoint for this project — briefing falls back to the "
509
+ "global pointer (possibly another project) or nothing"
510
+ )
511
+
512
+ # Format drift on the checkpoint that would back a briefing (proj, else the
513
+ # global fallback): a stored format_version that differs from the current one
514
+ # means the schema changed under it, so the briefing may render partially.
515
+ # Legacy checkpoints (no format_version) are silent — nothing to compare (#93).
516
+ active = proj if proj.get("exists") else glob
517
+ fv = active.get("format_version")
518
+ if fv and fv != serializer.PROMPT_VERSION:
519
+ warnings.append(
520
+ f"checkpoint format {fv} != current {serializer.PROMPT_VERSION} — "
521
+ f"schema changed; briefing may render partially (re-serialize to refresh)"
522
+ )
523
+
524
+ if outstanding:
525
+ n = len(outstanding)
526
+ warnings.append(
527
+ f"{n} session{'s' if n != 1 else ''} failed to serialize — run 'daimon heal'"
528
+ )
529
+
530
+ if not warnings:
531
+ verdict = "✓ fresh"
532
+ if glob.get("same_session_as_project"):
533
+ verdict += " — this project produced the most recent checkpoint"
534
+ return {"ok": True, "verdict": verdict, "warnings": []}
535
+ return {"ok": False, "verdict": "⚠ " + warnings[0], "warnings": warnings}
536
+
537
+
538
+ def _parse_serialize_log(path, now) -> dict | None:
539
+ """Tail of serialize.log -> {spawn, result}, or None when there's no log.
540
+
541
+ Lines from overlapping sessions interleave, so spawn and result are
542
+ reported INDEPENDENTLY (last of each kind) — no pairing is attempted.
543
+ """
544
+ try:
545
+ text = path.read_text(encoding="utf-8")
546
+ except OSError:
547
+ return None
548
+ spawn = result = None
549
+ for line in text.splitlines()[-200:]: # tail is plenty; the log only appends
550
+ line = line.strip()
551
+ m = _SPAWN_RE.match(line)
552
+ if m:
553
+ spawn = {"session_id": m.group(2), "timestamp": m.group(1)}
554
+ continue
555
+ m = _RESULT_OK_RE.match(line)
556
+ if m:
557
+ result = {"outcome": "success", "duration_seconds": int(m.group(1)), "line": line}
558
+ continue
559
+ m = _RESULT_ERR_RE.match(line)
560
+ if m:
561
+ duration = int(m.group(1)) if m.group(1) else None
562
+ result = {"outcome": "error", "duration_seconds": duration, "line": line}
563
+ if spawn:
564
+ try:
565
+ ts = datetime.strptime(spawn["timestamp"], "%Y-%m-%dT%H:%M:%SZ")
566
+ age = int(now - ts.replace(tzinfo=timezone.utc).timestamp())
567
+ spawn["age_seconds"] = age
568
+ spawn["age"] = _format_age(age)
569
+ except ValueError:
570
+ pass # unexpected stamp format: report the spawn without an age
571
+ return {"spawn": spawn, "result": result}
572
+
573
+
574
+ def _cmd_status(args) -> int:
575
+ now = time.time()
576
+ project = _resolve_project(args.project)
577
+ proj = _checkpoint_info(store.project_latest_path(project), now)
578
+ glob = _checkpoint_info(store.global_latest_path(), now)
579
+ same = bool(
580
+ proj["exists"] and glob["exists"] and proj["session_id"] == glob["session_id"]
581
+ )
582
+ glob["same_session_as_project"] = same
583
+ last = _parse_serialize_log(config.log_dir() / "serialize.log", now)
584
+ try:
585
+ _ledger_text = (config.log_dir() / "serialize.log").read_text(encoding="utf-8")
586
+ except OSError:
587
+ _ledger_text = ""
588
+ outstanding = _compute_outstanding(_ledger_text, now)
589
+ siblings = store.sibling_buckets(project)
590
+ health = _status_health(proj, glob, outstanding, siblings, now=now)
591
+ # ONE objective team line (#113), only when a team remote exists — the #84
592
+ # health-line rule: no line, no false alarms when the team feature is unused.
593
+ team = teamsync.status_line()
594
+ identity = {
595
+ "cwd": str(Path(args.project or ".").expanduser().resolve()),
596
+ "git_root": project,
597
+ "slug": store.project_slug(project),
598
+ }
599
+ # 0 = some checkpoint would back a briefing; 1 = neither pointer exists
600
+ # (cheap existence test for scripts / the FR #23 hook guard).
601
+ rc = 0 if (proj["exists"] or glob["exists"]) else 1
602
+
603
+ if args.json:
604
+ proj = {"dir": project, "slug": identity["slug"], **proj}
605
+ print(json.dumps(
606
+ {"project": proj, "global": glob, "last_serialize": last,
607
+ "outstanding": outstanding, "siblings": siblings, "health": health,
608
+ "team": team},
609
+ indent=2,
610
+ ))
611
+ return rc
612
+
613
+ render.render_status({
614
+ "project": project, "proj": proj, "glob": glob, "same": same, "last": last,
615
+ "outstanding": outstanding, "identity": identity, "health": health,
616
+ "team": team,
617
+ })
618
+ return rc
619
+
620
+
621
+ # ---- heal: opportunistic ONE-shot repair of the most recent FAILED serialize ----
622
+
623
+ # The transcript carried by a SerializeError result line (see _run_serialize):
624
+ # `error: <exc> (transcript: <path>) after <N>s`. Pre-flight errors (no API key,
625
+ # transcript-not-found) carry no `(transcript:...)` and so are not healable.
626
+ _HEAL_TRANSCRIPT_RE = re.compile(r"\(transcript: (.+?)\) after \d+s")
627
+
628
+ # Per-session ledger regexes (kept SEPARATE from _RESULT_OK_RE/_RESULT_ERR_RE,
629
+ # which _parse_serialize_log depends on). Success lines embed the session id in
630
+ # the checkpoint path: `wrote checkpoint: <dir>/<session>.json (took Ns)`.
631
+ _LEDGER_OK_RE = re.compile(r"^wrote checkpoint: (.+?) \(took \d+s\)")
632
+ _LEDGER_SKIP_RE = re.compile(r"^skipped serialize for (\S+):")
633
+ _LEDGER_PROJECT_RE = re.compile(r"project: (.*?)\)")
634
+
635
+
636
+ def _session_ledger(text: str, now: float) -> dict:
637
+ """Fold serialize.log into per-session terminal state. Unlike
638
+ _parse_serialize_log (last-of-each-kind, no pairing), this attributes every
639
+ line to its session_id — spawn regex group, success checkpoint-path stem, or
640
+ error transcript stem — so a failure is never masked by a later session's
641
+ success. Pre-flight errors (no transcript) carry no session and are dropped."""
642
+ sessions: dict = {}
643
+
644
+ def _entry(sid: str) -> dict:
645
+ return sessions.setdefault(sid, {
646
+ "spawned": False, "spawn_ts": None, "spawn_age": None, "project": None,
647
+ "result_kind": None, "result_line": None, "transcript": None,
648
+ "retried": False,
649
+ })
650
+
651
+ for line in text.splitlines()[-200:]:
652
+ line = line.strip()
653
+ m = _SPAWN_RE.match(line)
654
+ if m:
655
+ e = _entry(m.group(2))
656
+ e["spawned"] = True
657
+ try:
658
+ ts = datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%SZ")
659
+ e["spawn_ts"] = ts.replace(tzinfo=timezone.utc).timestamp()
660
+ e["spawn_age"] = int(now - e["spawn_ts"])
661
+ except ValueError:
662
+ pass
663
+ pm = _LEDGER_PROJECT_RE.search(line)
664
+ if pm:
665
+ raw = pm.group(1).strip()
666
+ e["project"] = raw if (raw and raw != "?") else None
667
+ if "retry serialize for" in line:
668
+ e["retried"] = True
669
+ continue
670
+ m = _LEDGER_OK_RE.match(line)
671
+ if m:
672
+ e = _entry(Path(m.group(1)).stem)
673
+ e["result_kind"] = "success"
674
+ e["result_line"] = line
675
+ e["transcript"] = None
676
+ continue
677
+ m = _LEDGER_SKIP_RE.match(line)
678
+ if m:
679
+ e = _entry(m.group(1))
680
+ e["result_kind"] = "skipped"
681
+ e["result_line"] = line
682
+ continue
683
+ if _RESULT_ERR_RE.match(line):
684
+ tm = _HEAL_TRANSCRIPT_RE.search(line)
685
+ if not tm:
686
+ continue # pre-flight error, no session to attribute
687
+ e = _entry(Path(tm.group(1)).stem)
688
+ e["result_kind"] = "error"
689
+ e["result_line"] = line
690
+ e["transcript"] = tm.group(1)
691
+ return sessions
692
+
693
+
694
+ def _outstanding_failures(ledger, now, has_checkpoint, ceiling, transcript_exists) -> list:
695
+ """Sessions still LOST — no checkpoint AND latest state != success.
696
+ `has_checkpoint(sid)` and `transcript_exists(path)` are injected so this
697
+ stays pure/testable. error+spawn+transcript-on-disk+not-retried -> healable
698
+ (exactly what heal will repair); error but retried -> retry-exhausted; error
699
+ but no spawn record or transcript gone -> unrecoverable (lost, heal can't
700
+ retry it); spawn with no result older than `ceiling` -> hung."""
701
+ out = []
702
+ for sid, e in ledger.items():
703
+ if e["result_kind"] in ("success", "skipped"):
704
+ continue
705
+ if has_checkpoint(sid):
706
+ continue
707
+ age = e["spawn_age"]
708
+ if e["result_kind"] == "error":
709
+ if e["retried"]:
710
+ cls = "retry-exhausted"
711
+ elif e["spawned"] and e["transcript"] and transcript_exists(e["transcript"]):
712
+ cls = "healable"
713
+ else:
714
+ cls = "unrecoverable"
715
+ out.append({"sid": sid, "kind": "error", "class": cls, "age": age,
716
+ "age_str": _format_age(age) if age is not None else "unknown",
717
+ "transcript": e["transcript"], "project": e["project"],
718
+ "spawned": e["spawned"], "line": e["result_line"]})
719
+ elif e["result_kind"] is None and e["spawned"] and age is not None and age > ceiling:
720
+ out.append({"sid": sid, "kind": "hung", "class": "hung", "age": age,
721
+ "age_str": _format_age(age), "transcript": None,
722
+ "project": e["project"], "spawned": True, "line": None})
723
+ out.sort(key=lambda f: (f["age"] is None, f["age"] or 0))
724
+ return out
725
+
726
+
727
+ def _compute_outstanding(text: str, now: float) -> list:
728
+ """Wire the pure ledger/classifier to the live store + filesystem. Single
729
+ source for both `status` (display) and `heal` (repair) so their notion of
730
+ 'outstanding' can never drift."""
731
+ return _outstanding_failures(
732
+ _session_ledger(text, now), now,
733
+ lambda sid: store.read_checkpoint(sid) is not None,
734
+ config.hung_after_seconds(),
735
+ lambda p: bool(p) and Path(p).exists(),
736
+ )
737
+
738
+
739
+ _HEAL_SKIP_REASON = {
740
+ "retry-exhausted": "retry already attempted, still failing",
741
+ "unrecoverable": "no spawn record or transcript gone — cannot auto-heal",
742
+ "hung": "spawned, no result (hung/killed) — transcript unavailable",
743
+ }
744
+
745
+
746
+ def _heal_plan(text, now) -> dict:
747
+ """Decide what `heal` will repair and why. Pure — `now` injected. Reuses the
748
+ SAME _compute_outstanding source as status, so their notion of healable agrees.
749
+ target = the newest `healable` (already gauntlet-vetted); every other outstanding
750
+ failure lands in `skipped` with a reason; `note` is the headline when there is no
751
+ target."""
752
+ outstanding = _compute_outstanding(text, now)
753
+ healable = [f for f in outstanding if f["class"] == "healable"]
754
+ target = None
755
+ if healable:
756
+ t = healable[0] # newest-first
757
+ target = {"sid": t["sid"], "transcript": t["transcript"],
758
+ "project": t["project"], "age_str": t["age_str"], "line": t["line"]}
759
+
760
+ skipped = []
761
+ for f in outstanding:
762
+ if target and f["sid"] == target["sid"]:
763
+ continue
764
+ if f["class"] == "healable":
765
+ reason = "newer failure took this run — re-run 'daimon heal' to reach it"
766
+ else:
767
+ reason = _HEAL_SKIP_REASON.get(f["class"], "not auto-repairable")
768
+ skipped.append({"sid": f["sid"], "age_str": f["age_str"], "reason": reason})
769
+
770
+ if target is not None:
771
+ note = ""
772
+ elif not outstanding:
773
+ note = ("nothing to heal — no serialize activity logged"
774
+ if not text.strip() else "nothing to heal — no outstanding failures")
775
+ else:
776
+ n = len(skipped)
777
+ note = f"nothing to heal — {n} failure{'s' if n != 1 else ''} can't be auto-repaired:"
778
+ return {"target": target, "skipped": skipped, "note": note}
779
+
780
+
781
+ def _cmd_heal(args) -> int:
782
+ """Explain the heal decision, then repair the newest healable session if safe.
783
+ Every no-op returns 0 (a no-op heal is never an error). `--dry-run` explains
784
+ without serializing."""
785
+ dry_run = getattr(args, "dry_run", False)
786
+ try:
787
+ text = (config.log_dir() / "serialize.log").read_text(encoding="utf-8")
788
+ except OSError:
789
+ text = ""
790
+ now = time.time()
791
+ plan = _heal_plan(text, now)
792
+ render.render_heal(plan, dry_run=dry_run)
793
+ if dry_run or plan["target"] is None:
794
+ return 0
795
+ t = plan["target"]
796
+ transcript_path = Path(t["transcript"])
797
+ if not transcript_path.exists():
798
+ print(f"heal aborted: transcript for {t['sid']} vanished")
799
+ return 0
800
+ prior = t["line"].split(" (transcript:")[0]
801
+ _append_retry_log(t["sid"], prior)
802
+ return _run_serialize(transcript_path, t["project"])
803
+
804
+
805
+ # ---- team: sidecar private-repo sync (#113) ----
806
+
807
+
808
+ def _cmd_team_init(args) -> int:
809
+ try:
810
+ dest = teamsync.init(args.remote_url)
811
+ except teamsync.TeamError as exc:
812
+ print(f"error: {exc}", file=sys.stderr)
813
+ return 1
814
+ print(f"initialized team sidecar: {dest}")
815
+ print("checkpoints now sync there — `daimon team sync` runs opportunistically "
816
+ "at session start")
817
+ return 0
818
+
819
+
820
+ def _cmd_team_sync(args) -> int:
821
+ """rc 0 for every sync-nothing-to-do shape (no git, no remotes, offline);
822
+ warnings go to stderr but never change the rc — a degraded sync is not a
823
+ user error."""
824
+ if not teamsync.git_available():
825
+ print("daimon team: git not found on PATH — sync skipped")
826
+ return 0
827
+ reports = teamsync.sync()
828
+ if not reports:
829
+ print("daimon team: no team remote configured — nothing to sync "
830
+ "(run `daimon team init <remote-url>`)")
831
+ return 0
832
+ for r in reports:
833
+ parts = [f"{r['committed']} committed", "pushed" if r["pushed"] else "no push"]
834
+ if r["fetched"]:
835
+ parts.append("fetched teammates' updates")
836
+ line = f"{r['slug']}: " + ", ".join(parts)
837
+ if r["notes"]:
838
+ line += " (" + "; ".join(r["notes"]) + ")"
839
+ print(line)
840
+ for w in r["warnings"]:
841
+ print(f"warning: {w}", file=sys.stderr)
842
+ return 0
843
+
844
+
845
+ def _cmd_team_status(args) -> int:
846
+ if not teamsync.git_available():
847
+ print("daimon team: git not found on PATH")
848
+ return 0
849
+ rows = teamsync.team_status()
850
+ if not rows:
851
+ print("no team remote configured — run `daimon team init <remote-url>`")
852
+ return 0
853
+ if args.json:
854
+ print(json.dumps(rows, indent=2))
855
+ return 0
856
+ for row in rows:
857
+ authors = ", ".join(row["authors"]) or "none yet"
858
+ print(f"{row['slug']}: {row['freshness']} — "
859
+ f"{row['unpushed']} unpushed checkpoint(s), authors: {authors}")
860
+ return 0
861
+
862
+
863
+ # ---- configure: detect/report the resolved backend + fill gaps in ~/.daimon/env ----
864
+
865
+
866
+ def _cmd_configure(args) -> int:
867
+ """Detect + report the resolved LLM backend; fill gaps in ~/.daimon/env.
868
+
869
+ Always prints a doctor view. With backend flags, writes non-interactively.
870
+ With no flags it is SAFE everywhere: it only prompts on a TTY when daimon is
871
+ not ready, and otherwise just prints guidance — it never blocks.
872
+ """
873
+ st = configure.status()
874
+ render.render_configure(st)
875
+
876
+ if args.backend:
877
+ updates = {"DAIMON_LLM_BACKEND": args.backend}
878
+ if args.backend == "litellm":
879
+ if args.api_key:
880
+ updates["DAIMON_LLM_API_KEY"] = args.api_key
881
+ if args.model:
882
+ updates["DAIMON_LLM_MODEL"] = args.model
883
+ if args.base_url:
884
+ updates["DAIMON_LLM_BASE_URL"] = args.base_url
885
+ elif args.backend == "command":
886
+ if args.command:
887
+ updates["DAIMON_LLM_COMMAND"] = args.command
888
+ if args.output:
889
+ updates["DAIMON_LLM_COMMAND_OUTPUT"] = args.output
890
+ # claude-cli: just pin the backend, no credentials needed.
891
+ path = configure.write_env(updates)
892
+ print(f"wrote {path}")
893
+ render.render_configure(configure.status()) # reprint the new resolved state
894
+ return 0
895
+
896
+ if st["ready"]:
897
+ return 0 # nothing to do
898
+ if not sys.stdin.isatty():
899
+ # Non-interactive and not ready: guide, never block.
900
+ print("not ready — re-run with --backend {litellm,command,claude-cli} "
901
+ "and the matching value flags, or run interactively in a terminal.")
902
+ return 0
903
+
904
+ # Interactive: prompt for a backend and its values.
905
+ backend = _prompt("backend [litellm/command/claude-cli]: ").strip() or "litellm"
906
+ updates = {"DAIMON_LLM_BACKEND": backend}
907
+ if backend == "litellm":
908
+ base_url = _prompt("base_url (blank for default): ").strip()
909
+ if base_url:
910
+ updates["DAIMON_LLM_BASE_URL"] = base_url
911
+ api_key = _prompt("api_key: ").strip()
912
+ if api_key:
913
+ updates["DAIMON_LLM_API_KEY"] = api_key
914
+ model = _prompt("model: ").strip()
915
+ if model:
916
+ updates["DAIMON_LLM_MODEL"] = model
917
+ elif backend == "command":
918
+ command = _prompt("command: ").strip()
919
+ if command:
920
+ updates["DAIMON_LLM_COMMAND"] = command
921
+ output = _prompt("output spec [text/json:<key>] (blank=text): ").strip()
922
+ if output:
923
+ updates["DAIMON_LLM_COMMAND_OUTPUT"] = output
924
+ # claude-cli: nothing more to ask.
925
+ path = configure.write_env(updates)
926
+ print(f"wrote {path}")
927
+ render.render_configure(configure.status())
928
+ return 0
929
+
930
+
931
+ def main(argv=None) -> int:
932
+ parser = argparse.ArgumentParser(
933
+ prog="daimon",
934
+ description="Cognitive checkpoints — serialize sessions, brief on resume.",
935
+ epilog="Examples:\n"
936
+ " daimon brief render the latest briefing\n"
937
+ " daimon status checkpoint presence + last serialize\n"
938
+ " daimon configure detect/repair the LLM backend\n",
939
+ formatter_class=argparse.RawDescriptionHelpFormatter,
940
+ )
941
+ parser.add_argument(
942
+ "--version", action="version", version=f"%(prog)s {__version__}"
943
+ )
944
+ sub = parser.add_subparsers(dest="cmd", required=True)
945
+
946
+ p_ser = sub.add_parser("serialize", help="serialize a transcript file into a checkpoint")
947
+ p_ser.add_argument("transcript", help="path to a text/markdown transcript")
948
+ p_ser.add_argument(
949
+ "--project",
950
+ help="project directory to route the checkpoint to "
951
+ "(default: DAIMON_PROJECT_DIR, then cwd)",
952
+ )
953
+ p_ser.set_defaults(func=_cmd_serialize)
954
+
955
+ p_wc = sub.add_parser(
956
+ "write-checkpoint",
957
+ help="store a checkpoint read as JSON on stdin (introspection path, #23)",
958
+ )
959
+ p_wc.add_argument(
960
+ "--project",
961
+ help="project directory to route the checkpoint to "
962
+ "(default: DAIMON_PROJECT_DIR, then cwd)",
963
+ )
964
+ p_wc.add_argument(
965
+ "--source", default="introspection",
966
+ help="provenance stamp for the checkpoint (default: introspection)",
967
+ )
968
+ p_wc.set_defaults(func=_cmd_write_checkpoint)
969
+
970
+ p_brief = sub.add_parser(
971
+ "brief", help="render the briefing from the latest checkpoint",
972
+ epilog="Examples:\n daimon brief\n daimon brief --project .\n DAIMON_PLAIN=1 daimon brief\n",
973
+ formatter_class=argparse.RawDescriptionHelpFormatter,
974
+ )
975
+ p_brief.add_argument(
976
+ "--project",
977
+ help="project directory to brief (default: DAIMON_PROJECT_DIR, then cwd)",
978
+ )
979
+ p_brief.add_argument(
980
+ "--team", action="store_true",
981
+ help="also show a 'Teammates' section: each teammate's active topic + "
982
+ "recent decisions from the shared team memory (#111)",
983
+ )
984
+ p_brief.set_defaults(func=_cmd_brief)
985
+
986
+ p_anchor = sub.add_parser(
987
+ "anchor", help="resolve a code symbol to an anchor block for a cognitive item",
988
+ epilog="Examples:\n daimon anchor daimon_briefing/cli.py _cmd_brief\n"
989
+ " daimon anchor pkg/mod.py MyClass.method --project .\n"
990
+ " daimon anchor pkg/mod.py fn --attach 'auth decision'\n",
991
+ formatter_class=argparse.RawDescriptionHelpFormatter,
992
+ )
993
+ p_anchor.add_argument("file", help="repo-relative path to the source file")
994
+ p_anchor.add_argument("symbol", help="symbol name or Class.method")
995
+ p_anchor.add_argument(
996
+ "--project", help="project root the file is relative to (default: cwd)"
997
+ )
998
+ p_anchor.add_argument(
999
+ "--attach", metavar="TEXT-MATCH",
1000
+ help="attach the anchor to the one checkpoint item whose text contains "
1001
+ "TEXT-MATCH (case-insensitive), re-writing the latest checkpoint",
1002
+ )
1003
+ p_anchor.set_defaults(func=_cmd_anchor)
1004
+
1005
+ p_recall = sub.add_parser(
1006
+ "recall", help="search local + team checkpoint history (FTS5)",
1007
+ epilog="Examples:\n"
1008
+ " daimon recall auth caching\n"
1009
+ " daimon recall gateway --all-projects --json\n",
1010
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1011
+ )
1012
+ p_recall.add_argument(
1013
+ "query", nargs="+",
1014
+ help="search terms (matched as words against item text and quotes)",
1015
+ )
1016
+ p_recall.add_argument(
1017
+ "--project",
1018
+ help="project directory to scope to (default: DAIMON_PROJECT_DIR, then cwd)",
1019
+ )
1020
+ p_recall.add_argument(
1021
+ "--all-projects", action="store_true",
1022
+ help="search across every project (lifts the project scope)",
1023
+ )
1024
+ p_recall.add_argument(
1025
+ "--json", action="store_true", help="machine-readable output"
1026
+ )
1027
+ p_recall.add_argument(
1028
+ "--limit", type=int, default=20, help="max results (default: 20)"
1029
+ )
1030
+ p_recall.set_defaults(func=_cmd_recall)
1031
+
1032
+ p_inject = sub.add_parser(
1033
+ "recall-inject",
1034
+ help="proactive-suggestion backend for the UserPromptSubmit hook (#125): "
1035
+ "prompt on stdin, prints 0-2 prior-work lines, rc 0 always",
1036
+ )
1037
+ p_inject.add_argument("--project", default=None,
1038
+ help="project dir for scoping (defaults to cwd detection)")
1039
+ p_inject.add_argument("--session", default=None,
1040
+ help="current session id (excluded from matches; keys the cooldown)")
1041
+ p_inject.set_defaults(func=_cmd_recall_inject)
1042
+
1043
+ p_status = sub.add_parser(
1044
+ "status", help="checkpoint presence/age + last serialize outcome",
1045
+ epilog="Examples:\n"
1046
+ " daimon status\n"
1047
+ " daimon status --project . --json\n",
1048
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1049
+ )
1050
+ p_status.add_argument(
1051
+ "--project",
1052
+ help="project directory to check (default: DAIMON_PROJECT_DIR, then cwd)",
1053
+ )
1054
+ p_status.add_argument(
1055
+ "--json", action="store_true", help="machine-readable output"
1056
+ )
1057
+ p_status.set_defaults(func=_cmd_status)
1058
+
1059
+ p_heal = sub.add_parser(
1060
+ "heal",
1061
+ help="re-serialize the most recent FAILED session if it can be done safely",
1062
+ )
1063
+ p_heal.add_argument(
1064
+ "--dry-run", action="store_true",
1065
+ help="explain what heal would repair (and why not) without serializing",
1066
+ )
1067
+ p_heal.set_defaults(func=_cmd_heal)
1068
+
1069
+ p_team = sub.add_parser(
1070
+ "team", help="shared team memory: sidecar repo init/sync/status (#113)",
1071
+ epilog="Examples:\n"
1072
+ " daimon team init git@github.com:org/team-memory.git\n"
1073
+ " daimon team sync\n"
1074
+ " daimon team status\n",
1075
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1076
+ )
1077
+ team_sub = p_team.add_subparsers(dest="team_cmd", required=True)
1078
+ pt_init = team_sub.add_parser(
1079
+ "init", help="clone the private team sidecar repo (empty remote OK)"
1080
+ )
1081
+ pt_init.add_argument("remote_url", help="git remote URL of the PRIVATE team repo")
1082
+ pt_init.set_defaults(func=_cmd_team_init)
1083
+ pt_sync = team_sub.add_parser(
1084
+ "sync", help="commit+push own checkpoints; fetch teammates' only on "
1085
+ "remote change (ls-remote gate)"
1086
+ )
1087
+ pt_sync.add_argument(
1088
+ "--project",
1089
+ help="accepted for CLI symmetry; sync is currently project-agnostic "
1090
+ "(all own checkpoints sync regardless of project)",
1091
+ )
1092
+ pt_sync.set_defaults(func=_cmd_team_sync)
1093
+ pt_status = team_sub.add_parser(
1094
+ "status", help="per-remote freshness, own unpushed count, authors seen"
1095
+ )
1096
+ pt_status.add_argument("--json", action="store_true", help="machine-readable output")
1097
+ pt_status.set_defaults(func=_cmd_team_status)
1098
+
1099
+ p_cfg = sub.add_parser(
1100
+ "configure",
1101
+ help="detect the resolved LLM backend and fill gaps in ~/.daimon/env",
1102
+ )
1103
+ p_cfg.add_argument(
1104
+ "--backend", choices=("litellm", "command", "claude-cli"),
1105
+ help="non-interactive: pin this backend and write the value flags below",
1106
+ )
1107
+ p_cfg.add_argument("--api-key", help="litellm: DAIMON_LLM_API_KEY")
1108
+ p_cfg.add_argument("--model", help="litellm: DAIMON_LLM_MODEL")
1109
+ p_cfg.add_argument("--base-url", help="litellm: DAIMON_LLM_BASE_URL")
1110
+ p_cfg.add_argument("--command", help="command: DAIMON_LLM_COMMAND")
1111
+ p_cfg.add_argument("--output", help="command: DAIMON_LLM_COMMAND_OUTPUT (text|json:<key>)")
1112
+ p_cfg.set_defaults(func=_cmd_configure)
1113
+
1114
+ args = parser.parse_args(argv)
1115
+ return args.func(args)
1116
+
1117
+
1118
+ if __name__ == "__main__":
1119
+ raise SystemExit(main())