agentdir-cli 0.7.5__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.
agentdir/control.py ADDED
@@ -0,0 +1,729 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .audit import audit_claims, audit_session, claims_audit_gaps, session_audit_gaps
8
+ from .context import (
9
+ audit_context_pack,
10
+ build_context_pack,
11
+ emit_context_pack,
12
+ )
13
+ from .daemon import memory_daemon_status
14
+ from .doctor import run_doctor
15
+ from .events import emit_event
16
+ from .federation import doctor_registered_roots, list_registered_roots, list_root_groups
17
+ from .git import git_branch, git_head, git_status_short, workspace_name
18
+ from .index import rebuild_index
19
+ from .memory import DEFAULT_MIN_SCORE, RETRIEVAL_HYBRID, memory_stats
20
+ from .query import query_messages
21
+ from .review import evidence_brief, evidence_rows, format_evidence, format_summary, summarize_session
22
+ from .rendering import rich_status
23
+ from .sessions import SessionState, end_session, ensure_session, read_current_session, require_current_session
24
+ from .store import AgentDirError, init_root, paths_for, require_root
25
+
26
+ EVENT_WORK_STARTED = "work.started"
27
+ EVENT_WORK_FINISHED = "work.finished"
28
+ EVENT_WORK_REPORT_FINAL = "work.report.final"
29
+
30
+
31
+ def adopt_repo(
32
+ root: str | Path,
33
+ *,
34
+ install_hooks_result: list[dict[str, Any]],
35
+ codex_skill_path: str | None,
36
+ generic_guidance_path: str | None = None,
37
+ integrations: list[dict[str, Any]] | None = None,
38
+ gitignore: dict[str, Any] | None = None,
39
+ ) -> dict[str, Any]:
40
+ paths = init_root(root)
41
+ doctor = run_doctor(paths.root)
42
+ return {
43
+ "root": str(paths.root),
44
+ "version": _store_version(paths.root),
45
+ "hooks": install_hooks_result,
46
+ "codex_skill": codex_skill_path,
47
+ "generic_guidance": generic_guidance_path,
48
+ "integrations": integrations or [],
49
+ "gitignore": gitignore,
50
+ "doctor": doctor.as_dict(),
51
+ "next": "agentdir work start \"<task>\"",
52
+ }
53
+
54
+
55
+ def build_status(
56
+ root: str | Path,
57
+ *,
58
+ scope: str | None = None,
59
+ rebuild: bool = True,
60
+ ) -> dict[str, Any]:
61
+ paths = paths_for(root)
62
+ initialized = (paths.meta / "VERSION").is_file()
63
+ status: dict[str, Any] = {
64
+ "root": {
65
+ "path": str(paths.root),
66
+ "scope": scope or "default",
67
+ "initialized": initialized,
68
+ "version": _store_version(paths.root) if initialized else None,
69
+ },
70
+ "git": {
71
+ "workspace": workspace_name(),
72
+ "branch": git_branch(),
73
+ "head": git_head(),
74
+ "dirty": bool(git_status_short()),
75
+ },
76
+ }
77
+ if not initialized:
78
+ doctor = run_doctor(paths.root)
79
+ status.update(
80
+ {
81
+ "session": {"active": False, "current": None},
82
+ "context": {"latest_pack": None},
83
+ "evidence": {"count": 0},
84
+ "memory": {"indexed": False},
85
+ "federation": {"roots": [], "groups": []},
86
+ "health": doctor.as_dict(),
87
+ }
88
+ )
89
+ return status
90
+
91
+ if rebuild:
92
+ rebuild_index(paths.root)
93
+
94
+ current = read_current_session(paths.root)
95
+ summary: dict[str, Any] | None = None
96
+ evidence_count = 0
97
+ if current:
98
+ summary = summarize_session(paths.root, current.session_id, rebuild=False)
99
+ evidence_count = len(evidence_rows(paths.root, current.session_id, rebuild=False))
100
+
101
+ latest_pack = latest_context_pack(
102
+ paths.root,
103
+ current.session_id if current else None,
104
+ fallback_any=True,
105
+ rebuild=False,
106
+ )
107
+ memory = _safe_memory_stats(paths.root)
108
+ memory["daemon"] = memory_daemon_status(paths.root)
109
+ roots = doctor_registered_roots(paths.root)
110
+ groups = list_root_groups(paths.root)
111
+ doctor = run_doctor(paths.root)
112
+ status.update(
113
+ {
114
+ "session": {
115
+ "active": current is not None and current.status == "active",
116
+ "current": asdict(current) if current else None,
117
+ "summary": summary,
118
+ },
119
+ "context": {"latest_pack": latest_pack},
120
+ "evidence": {"count": evidence_count},
121
+ "memory": memory,
122
+ "federation": {
123
+ "roots": roots,
124
+ "groups": groups,
125
+ "registered_roots": len(roots),
126
+ "stale_roots": sum(1 for root in roots if root.get("stale")),
127
+ },
128
+ "health": doctor.as_dict(),
129
+ }
130
+ )
131
+ return status
132
+
133
+
134
+ def format_status(status: dict[str, Any]) -> str:
135
+ rendered = rich_status(status)
136
+ if rendered is not None:
137
+ return rendered
138
+ root = status["root"]
139
+ session = status["session"]
140
+ context = status["context"]
141
+ evidence = status["evidence"]
142
+ memory = status["memory"]
143
+ federation = status["federation"]
144
+ health = status["health"]
145
+ lines = [
146
+ "AgentDir Status",
147
+ "",
148
+ f"root={root['path']}",
149
+ f"scope={root['scope']}",
150
+ f"initialized={str(root['initialized']).lower()}",
151
+ f"version={root.get('version') or ''}",
152
+ "",
153
+ f"session_active={str(session['active']).lower()}",
154
+ f"session={session['current']['session_id'] if session.get('current') else ''}",
155
+ f"evidence={evidence['count']}",
156
+ f"context_pack={context['latest_pack']['pack_id'] if context.get('latest_pack') else ''}",
157
+ "",
158
+ f"memory_indexed={str(memory.get('indexed', False)).lower()}",
159
+ f"memory_documents={memory.get('memory_documents', 0)}",
160
+ f"passages={memory.get('passages', 0)}",
161
+ "",
162
+ f"registered_roots={federation['registered_roots']}",
163
+ f"stale_roots={federation['stale_roots']}",
164
+ "",
165
+ f"doctor_ok={str(health['ok']).lower()}",
166
+ ]
167
+ for warning in health.get("warnings", []):
168
+ lines.append(f"warning: {warning}")
169
+ for error in health.get("errors", []):
170
+ lines.append(f"error: {error}")
171
+ return "\n".join(lines).rstrip() + "\n"
172
+
173
+
174
+ def start_work(
175
+ root: str | Path,
176
+ task: str,
177
+ *,
178
+ actor: str = "agent",
179
+ emit_context: bool = False,
180
+ federated: bool = False,
181
+ federation_group: str | None = None,
182
+ memory_limit: int = 8,
183
+ evidence_limit: int = 20,
184
+ recent_limit: int = 5,
185
+ min_score: float = DEFAULT_MIN_SCORE,
186
+ retrieval_mode: str = RETRIEVAL_HYBRID,
187
+ ) -> dict[str, Any]:
188
+ paths = init_root(root)
189
+ session = ensure_session(paths.root, title=task, actor=actor)
190
+ emit_event(
191
+ paths.root,
192
+ session_id=session.session_id,
193
+ event_type=EVENT_WORK_STARTED,
194
+ subject=f"work started: {task}",
195
+ body=f"task={task}\nsession_id={session.session_id}",
196
+ from_actor=actor,
197
+ workspace=session.workspace,
198
+ git_head=session.git_head,
199
+ )
200
+ pack = build_context_pack(
201
+ paths.root,
202
+ task,
203
+ session_id=session.session_id,
204
+ memory_limit=memory_limit,
205
+ evidence_limit=evidence_limit,
206
+ recent_limit=recent_limit,
207
+ min_score=min_score,
208
+ federated=federated or bool(federation_group),
209
+ federation_group=federation_group,
210
+ retrieval_mode=retrieval_mode,
211
+ exclude_session_from_memory=True,
212
+ )
213
+ emitted = None
214
+ if emit_context:
215
+ emitted = emit_context_pack(
216
+ paths.root,
217
+ pack,
218
+ selection_policy={
219
+ "memory_limit": memory_limit,
220
+ "evidence_limit": evidence_limit,
221
+ "recent_limit": recent_limit,
222
+ "min_score": min_score,
223
+ "federated": federated or bool(federation_group),
224
+ "federation_group": federation_group,
225
+ "retrieval_mode": retrieval_mode,
226
+ },
227
+ actor=actor,
228
+ scope=federation_group or ("federated" if federated else "project"),
229
+ )
230
+ return {
231
+ "session": asdict(session),
232
+ "task": task,
233
+ "context": {
234
+ "memory_hits": len(pack.get("memory_hits") or []),
235
+ "evidence": len(pack.get("evidence") or []),
236
+ "recent_session_summaries": len(pack.get("recent_session_summaries") or []),
237
+ "federated": bool(pack.get("federated")),
238
+ "federation_group": federation_group,
239
+ },
240
+ "context_pack": emitted.manifest if emitted else None,
241
+ "event_path": str(emitted.event_path) if emitted else None,
242
+ }
243
+
244
+
245
+ def format_work_start(result: dict[str, Any]) -> str:
246
+ session = result["session"]
247
+ context = result["context"]
248
+ lines = [
249
+ f"session={session['session_id']}",
250
+ f"task={result['task']}",
251
+ f"memory_hits={context['memory_hits']}",
252
+ f"evidence={context['evidence']}",
253
+ f"recent_session_summaries={context['recent_session_summaries']}",
254
+ f"federated={str(context['federated']).lower()}",
255
+ ]
256
+ if context.get("federation_group"):
257
+ lines.append(f"group={context['federation_group']}")
258
+ if result.get("context_pack"):
259
+ lines.append(f"context_pack={result['context_pack']['pack_id']}")
260
+ return "\n".join(lines) + "\n"
261
+
262
+
263
+ def finish_work(
264
+ root: str | Path,
265
+ *,
266
+ session_id: str | None = None,
267
+ actor: str = "agent",
268
+ run_health_check: bool = True,
269
+ end: bool = True,
270
+ claims_text: str | None = None,
271
+ ) -> dict[str, Any]:
272
+ paths = require_root(root)
273
+ session = _resolve_session(paths.root, session_id)
274
+ report = build_final_report(
275
+ paths.root,
276
+ session_id=session.session_id,
277
+ run_health_check=run_health_check,
278
+ claims_text=claims_text,
279
+ finishing=True,
280
+ )
281
+ rendered = format_final_report(report)
282
+ event = emit_event(
283
+ paths.root,
284
+ session_id=session.session_id,
285
+ event_type=EVENT_WORK_REPORT_FINAL,
286
+ subject="final report",
287
+ body=rendered,
288
+ from_actor=actor,
289
+ workspace=session.workspace,
290
+ git_head=git_head(),
291
+ )
292
+ emit_event(
293
+ paths.root,
294
+ session_id=session.session_id,
295
+ event_type=EVENT_WORK_FINISHED,
296
+ subject=f"work finished: {session.title}",
297
+ body=f"session_id={session.session_id}\nreport_event={event.path}",
298
+ from_actor=actor,
299
+ workspace=session.workspace,
300
+ git_head=git_head(),
301
+ )
302
+ ended = None
303
+ if end:
304
+ ended = end_session(
305
+ paths.root,
306
+ summary="AgentDir work finished; final report emitted.",
307
+ actor=actor,
308
+ )
309
+ return {
310
+ "report": report,
311
+ "rendered": rendered,
312
+ "event_path": str(event.path),
313
+ "ended_session": asdict(ended) if ended else None,
314
+ }
315
+
316
+
317
+ def build_final_report(
318
+ root: str | Path,
319
+ *,
320
+ session_id: str | None = None,
321
+ run_health_check: bool = True,
322
+ claims_text: str | None = None,
323
+ finishing: bool = False,
324
+ ) -> dict[str, Any]:
325
+ paths = require_root(root)
326
+ session = _resolve_session(paths.root, session_id)
327
+ rebuild_index(paths.root)
328
+ summary = summarize_session(paths.root, session.session_id, rebuild=False)
329
+ evidence = evidence_rows(paths.root, session.session_id, rebuild=False)
330
+ latest_pack = latest_context_pack(paths.root, session.session_id, rebuild=False)
331
+ context_audit = None
332
+ if latest_pack:
333
+ try:
334
+ context_audit = audit_context_pack(paths.root, latest_pack["pack_id"], rebuild=False)
335
+ except AgentDirError as exc:
336
+ context_audit = {"error": str(exc), "pack_id": latest_pack["pack_id"]}
337
+ doctor = run_doctor(paths.root).as_dict() if run_health_check else None
338
+ session_audit = audit_session(
339
+ paths.root,
340
+ session.session_id,
341
+ finishing=finishing,
342
+ run_health_check=run_health_check,
343
+ rebuild=False,
344
+ summary=summary,
345
+ evidence=evidence,
346
+ latest_pack=latest_pack,
347
+ context_audit=context_audit,
348
+ doctor=doctor,
349
+ )
350
+ claims_audit = (
351
+ audit_claims(
352
+ paths.root,
353
+ claims_text,
354
+ session.session_id,
355
+ rebuild=False,
356
+ summary=summary,
357
+ evidence=evidence,
358
+ )
359
+ if claims_text is not None
360
+ else None
361
+ )
362
+ gaps = _known_gaps(summary, evidence, context_audit, doctor, session_audit, claims_audit)
363
+ brief = evidence_brief(evidence)
364
+ handoff = _agent_handoff(
365
+ summary=summary,
366
+ evidence_brief_payload=brief,
367
+ context_audit=context_audit,
368
+ doctor=doctor,
369
+ session_audit=session_audit,
370
+ claims_audit=claims_audit,
371
+ known_gaps=gaps,
372
+ )
373
+ return {
374
+ "task": session.title,
375
+ "session": asdict(session),
376
+ "summary": summary,
377
+ "evidence": evidence,
378
+ "evidence_brief": brief,
379
+ "context": {
380
+ "latest_pack": latest_pack,
381
+ "audit": context_audit,
382
+ },
383
+ "session_audit": session_audit,
384
+ "claim_support": claims_audit,
385
+ "agent_handoff": handoff,
386
+ "git": {
387
+ "workspace": session.workspace,
388
+ "branch": git_branch(),
389
+ "head": git_head(),
390
+ "dirty": bool(git_status_short()),
391
+ },
392
+ "health": doctor,
393
+ "known_gaps": gaps,
394
+ }
395
+
396
+
397
+ def format_final_report(report: dict[str, Any]) -> str:
398
+ summary = report["summary"]
399
+ context = report["context"]
400
+ health = report.get("health")
401
+ session_audit = report.get("session_audit") or {}
402
+ claim_support = report.get("claim_support")
403
+ handoff = report.get("agent_handoff") or {}
404
+ lines = [
405
+ "# AgentDir Final Report",
406
+ "",
407
+ "## Task",
408
+ "",
409
+ report["task"],
410
+ "",
411
+ "## Session",
412
+ "",
413
+ f"- session: `{summary['session_id']}`",
414
+ f"- events_before_final_report: {summary['events']}",
415
+ f"- tool_results: {summary['tool_results']}",
416
+ f"- failed_tools: {summary['failed_tools']}",
417
+ "",
418
+ "## Evidence",
419
+ "",
420
+ ]
421
+ if report["evidence"]:
422
+ lines.append("```text")
423
+ lines.append(format_evidence(report["evidence"]))
424
+ lines.append("```")
425
+ else:
426
+ lines.append("No evidence captured.")
427
+ lines.extend(["", "## Context", ""])
428
+ latest_pack = context.get("latest_pack")
429
+ audit = context.get("audit")
430
+ if latest_pack:
431
+ lines.append(f"- pack: `{latest_pack['pack_id']}`")
432
+ else:
433
+ lines.append("- pack: none")
434
+ if audit and not audit.get("error"):
435
+ lines.append(f"- retrieved: {audit['retrieved_count']}")
436
+ lines.append(f"- consumed: {audit['consumed_count']}")
437
+ lines.append(f"- cited: {audit['cited_count']}")
438
+ lines.append(f"- evidence_backed: {audit['evidence_backed_count']}")
439
+ elif audit and audit.get("error"):
440
+ lines.append(f"- audit_error: {audit['error']}")
441
+ lines.extend(["", "## Session Audit", ""])
442
+ if session_audit:
443
+ lines.append(f"- ok: {str(session_audit.get('ok', False)).lower()}")
444
+ for check in session_audit.get("checks") or []:
445
+ lines.append(f"- {check['id']}: {check['status']} - {check['message']}")
446
+ else:
447
+ lines.append("- audit: not run")
448
+ if claim_support is not None:
449
+ lines.extend(["", "## Claim Support", ""])
450
+ if claim_support.get("claims"):
451
+ for claim in claim_support["claims"]:
452
+ lines.append(f"- {claim['family']}: {claim['status']} - {claim['message']}")
453
+ else:
454
+ lines.append("- claims: none detected")
455
+ lines.extend(["", "## Agent Handoff", ""])
456
+ if handoff:
457
+ lines.append(f"- status: {handoff.get('status')}")
458
+ actions = handoff.get("recommended_agent_actions") or []
459
+ if actions:
460
+ for action in actions:
461
+ lines.append(f"- action: {action}")
462
+ else:
463
+ lines.append("- action: none")
464
+ else:
465
+ lines.append("- handoff: not available")
466
+ lines.extend(["", "## Health", ""])
467
+ if health:
468
+ lines.append(f"- doctor_ok: {str(health['ok']).lower()}")
469
+ for warning in health.get("warnings", []):
470
+ lines.append(f"- warning: {warning}")
471
+ for error in health.get("errors", []):
472
+ lines.append(f"- error: {error}")
473
+ else:
474
+ lines.append("- doctor: not run")
475
+ lines.extend(["", "## Known Gaps", ""])
476
+ if report["known_gaps"]:
477
+ for gap in report["known_gaps"]:
478
+ lines.append(f"- {gap}")
479
+ else:
480
+ lines.append("- none")
481
+ return "\n".join(lines).rstrip() + "\n"
482
+
483
+
484
+ def _agent_handoff(
485
+ *,
486
+ summary: dict[str, Any],
487
+ evidence_brief_payload: dict[str, Any],
488
+ context_audit: dict[str, Any] | None,
489
+ doctor: dict[str, Any] | None,
490
+ session_audit: dict[str, Any] | None,
491
+ claims_audit: dict[str, Any] | None,
492
+ known_gaps: list[str],
493
+ ) -> dict[str, Any]:
494
+ failed_evidence = evidence_brief_payload.get("failed_evidence") or []
495
+ fail_checks = [
496
+ check
497
+ for check in (session_audit or {}).get("checks", [])
498
+ if check.get("status") == "fail"
499
+ ]
500
+ unsupported_claims = []
501
+ contradicted_claims = []
502
+ if claims_audit:
503
+ unsupported_claims = [
504
+ claim
505
+ for claim in claims_audit.get("claims", [])
506
+ if claim.get("status") == "unsupported"
507
+ ]
508
+ contradicted_claims = [
509
+ claim
510
+ for claim in claims_audit.get("claims", [])
511
+ if claim.get("status") == "contradicted"
512
+ ]
513
+ doctor_errors = (doctor or {}).get("errors") or []
514
+ needs_attention = bool(failed_evidence or fail_checks or unsupported_claims or contradicted_claims or doctor_errors)
515
+ return {
516
+ "status": "needs_attention" if needs_attention else "ok",
517
+ "verification": evidence_brief_payload.get("families") or [],
518
+ "failed_evidence": failed_evidence,
519
+ "claim_support": claims_audit,
520
+ "context_lineage": _context_lineage(context_audit),
521
+ "known_gaps": known_gaps,
522
+ "recommended_agent_actions": _recommended_agent_actions(
523
+ summary=summary,
524
+ failed_evidence=failed_evidence,
525
+ unsupported_claims=unsupported_claims,
526
+ contradicted_claims=contradicted_claims,
527
+ doctor_errors=doctor_errors,
528
+ session_audit=session_audit,
529
+ ),
530
+ "final_response_guidance": _final_response_guidance(
531
+ failed_evidence=failed_evidence,
532
+ unsupported_claims=unsupported_claims,
533
+ contradicted_claims=contradicted_claims,
534
+ doctor_errors=doctor_errors,
535
+ ),
536
+ }
537
+
538
+
539
+ def _context_lineage(context_audit: dict[str, Any] | None) -> dict[str, Any]:
540
+ if context_audit is None:
541
+ return {
542
+ "pack_id": None,
543
+ "retrieved": 0,
544
+ "consumed": 0,
545
+ "cited": 0,
546
+ "evidence_backed": 0,
547
+ "ok": False,
548
+ }
549
+ if context_audit.get("error"):
550
+ return {
551
+ "pack_id": context_audit.get("pack_id"),
552
+ "retrieved": 0,
553
+ "consumed": 0,
554
+ "cited": 0,
555
+ "evidence_backed": 0,
556
+ "ok": False,
557
+ "error": context_audit.get("error"),
558
+ }
559
+ retrieved = context_audit.get("retrieved_count", 0)
560
+ consumed = context_audit.get("consumed_count", 0)
561
+ cited = context_audit.get("cited_count", 0)
562
+ return {
563
+ "pack_id": context_audit.get("pack_id"),
564
+ "retrieved": retrieved,
565
+ "consumed": consumed,
566
+ "cited": cited,
567
+ "evidence_backed": context_audit.get("evidence_backed_count", 0),
568
+ "ok": not ((retrieved and not consumed) or (consumed and not cited)),
569
+ }
570
+
571
+
572
+ def _recommended_agent_actions(
573
+ *,
574
+ summary: dict[str, Any],
575
+ failed_evidence: list[dict[str, Any]],
576
+ unsupported_claims: list[dict[str, Any]],
577
+ contradicted_claims: list[dict[str, Any]],
578
+ doctor_errors: list[str],
579
+ session_audit: dict[str, Any] | None,
580
+ ) -> list[str]:
581
+ actions: list[str] = []
582
+ if failed_evidence:
583
+ actions.append("Inspect failed evidence and rerun the relevant check after fixing it.")
584
+ if contradicted_claims:
585
+ actions.append("Do not claim contradicted checks passed until newer successful evidence exists.")
586
+ if unsupported_claims:
587
+ families = ", ".join(sorted({str(claim.get("family")) for claim in unsupported_claims}))
588
+ actions.append(f"Capture evidence or remove unsupported final-response claims for: {families}.")
589
+ if doctor_errors:
590
+ actions.append("Run `agentdir doctor` and resolve health errors before treating the session as clean.")
591
+ if summary.get("tool_results", 0) == 0:
592
+ actions.append("Run evidence-bearing verification through `agentdir run -- <command>` when final claims need support.")
593
+ if session_audit:
594
+ failing_ids = [
595
+ check.get("id")
596
+ for check in session_audit.get("checks", [])
597
+ if check.get("status") == "fail" and check.get("id") != "failed_tool_results"
598
+ ]
599
+ for check_id in failing_ids:
600
+ actions.append(f"Resolve failing session audit check: {check_id}.")
601
+ return actions
602
+
603
+
604
+ def _final_response_guidance(
605
+ *,
606
+ failed_evidence: list[dict[str, Any]],
607
+ unsupported_claims: list[dict[str, Any]],
608
+ contradicted_claims: list[dict[str, Any]],
609
+ doctor_errors: list[str],
610
+ ) -> list[str]:
611
+ guidance = [
612
+ "Base final verification claims only on supported AgentDir evidence.",
613
+ "Mention AgentDir only when it clarifies evidence, blockers, or setup problems.",
614
+ ]
615
+ if failed_evidence:
616
+ guidance.append("Call out failed checks plainly instead of summarizing the session as clean.")
617
+ if unsupported_claims:
618
+ guidance.append("Avoid unsupported test, lint, typecheck, build, doctor, or release claims.")
619
+ if contradicted_claims:
620
+ guidance.append("Treat contradicted claims as blockers until newer passing evidence exists.")
621
+ if doctor_errors:
622
+ guidance.append("Surface doctor errors as setup or health blockers.")
623
+ return guidance
624
+
625
+
626
+ def latest_context_pack(
627
+ root: str | Path,
628
+ session_id: str | None = None,
629
+ *,
630
+ fallback_any: bool = False,
631
+ rebuild: bool = True,
632
+ ) -> dict[str, Any] | None:
633
+ if rebuild:
634
+ rebuild_index(root)
635
+ rows = query_messages(
636
+ root,
637
+ session_id=session_id,
638
+ event_type="context.pack.created",
639
+ limit=10_000,
640
+ )
641
+ if not rows and session_id and fallback_any:
642
+ rows = query_messages(root, event_type="context.pack.created", limit=10_000)
643
+ for row in reversed(rows):
644
+ pack_id = _pack_id_from_body(row.get("body_text") or "")
645
+ if pack_id:
646
+ return {
647
+ "pack_id": pack_id,
648
+ "session_id": row.get("session_id"),
649
+ "event_path": row.get("file_path"),
650
+ "subject": row.get("subject"),
651
+ "date_utc": row.get("date_utc"),
652
+ }
653
+ return None
654
+
655
+
656
+ def _resolve_session(root: str | Path, session_id: str | None) -> SessionState:
657
+ if session_id:
658
+ current = read_current_session(root)
659
+ if current and current.session_id == session_id:
660
+ return current
661
+ summary = summarize_session(root, session_id)
662
+ return SessionState(
663
+ session_id=session_id,
664
+ title=session_id,
665
+ actor="agent",
666
+ workspace=workspace_name(),
667
+ git_head=git_head(),
668
+ started_at=summary.get("first_event") or "",
669
+ status="unknown",
670
+ )
671
+ return require_current_session(root)
672
+
673
+
674
+ def _safe_memory_stats(root: str | Path) -> dict[str, Any]:
675
+ try:
676
+ stats = memory_stats(root)
677
+ except Exception as exc:
678
+ return {"indexed": False, "error": str(exc)}
679
+ return {"indexed": True, **stats}
680
+
681
+
682
+ def _store_version(root: str | Path) -> str | None:
683
+ path = paths_for(root).meta / "VERSION"
684
+ if not path.is_file():
685
+ return None
686
+ return path.read_text(encoding="utf-8").strip()
687
+
688
+
689
+ def _pack_id_from_body(body: str) -> str | None:
690
+ for line in body.splitlines():
691
+ if line.startswith("pack_id="):
692
+ return line.split("=", 1)[1].strip() or None
693
+ return None
694
+
695
+
696
+ def _known_gaps(
697
+ summary: dict[str, Any],
698
+ evidence: list[dict[str, Any]],
699
+ context_audit: dict[str, Any] | None,
700
+ doctor: dict[str, Any] | None,
701
+ session_audit: dict[str, Any] | None = None,
702
+ claims_audit: dict[str, Any] | None = None,
703
+ ) -> list[str]:
704
+ gaps: list[str] = []
705
+ if session_audit:
706
+ gaps.extend(session_audit_gaps(session_audit))
707
+ if claims_audit:
708
+ gaps.extend(claims_audit_gaps(claims_audit))
709
+ if session_audit:
710
+ return gaps
711
+ if summary.get("failed_tools"):
712
+ gaps.append(f"{summary['failed_tools']} captured tool result(s) failed")
713
+ if not evidence:
714
+ gaps.append("No evidence captured")
715
+ if context_audit is None:
716
+ gaps.append("No context pack recorded")
717
+ elif context_audit.get("error"):
718
+ gaps.append(f"Context audit failed: {context_audit['error']}")
719
+ else:
720
+ if context_audit.get("retrieved_count", 0) and not context_audit.get("consumed_count", 0):
721
+ gaps.append("Context pack emitted but no sources consumed")
722
+ if context_audit.get("consumed_count", 0) and not context_audit.get("cited_count", 0):
723
+ gaps.append("Context sources consumed but not cited")
724
+ if doctor:
725
+ for warning in doctor.get("warnings", []):
726
+ gaps.append(f"Doctor warning: {warning}")
727
+ for error in doctor.get("errors", []):
728
+ gaps.append(f"Doctor error: {error}")
729
+ return gaps