nexo-brain 7.30.20 → 7.30.22

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.
@@ -0,0 +1,1101 @@
1
+ from __future__ import annotations
2
+
3
+ """Operational Closure Plane MVP.
4
+
5
+ Read-only adapters discover unfinished operational work and project it into a
6
+ canonical closure_items table. Verification/close calls only mutate closure
7
+ metadata; they never execute the source action.
8
+ """
9
+
10
+ import datetime as _dt
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import time as _time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ OPEN_STATES = {"open", "waiting", "verified"}
20
+ FINAL_STATES = {"closed", "rejected", "stale"}
21
+ STATE_ALIASES = {
22
+ "ready": "open",
23
+ "waiting_user": "waiting",
24
+ "blocked": "waiting",
25
+ "done": "closed",
26
+ }
27
+ READINESS_STATES = {
28
+ "available",
29
+ "missing_tool",
30
+ "missing_credential",
31
+ "needs_user_permission",
32
+ "unsafe",
33
+ "external_blocker",
34
+ "unknown",
35
+ }
36
+ TRIAGE_STATES = OPEN_STATES | FINAL_STATES
37
+
38
+
39
+ def _canonical_state(value: Any) -> str:
40
+ clean = str(value or "").strip().lower()
41
+ return STATE_ALIASES.get(clean, clean)
42
+
43
+
44
+ def _now_iso() -> str:
45
+ return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
46
+
47
+
48
+ def _today() -> str:
49
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d")
50
+
51
+
52
+ def _hash_id(prefix: str, value: str, length: int = 16) -> str:
53
+ digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
54
+ return f"{prefix}-{digest}"
55
+
56
+
57
+ def _as_dict(row: Any) -> dict[str, Any]:
58
+ if row is None:
59
+ return {}
60
+ if hasattr(row, "keys"):
61
+ return {key: row[key] for key in row.keys()}
62
+ return dict(row)
63
+
64
+
65
+ def _table_exists(conn, table: str) -> bool:
66
+ row = conn.execute(
67
+ "SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
68
+ (table,),
69
+ ).fetchone()
70
+ return row is not None
71
+
72
+
73
+ def _safe_json(payload: Any) -> str:
74
+ try:
75
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True)
76
+ except Exception:
77
+ return "{}"
78
+
79
+
80
+ def _parse_time(value: Any) -> _dt.datetime | None:
81
+ if value is None or value == "":
82
+ return None
83
+ if isinstance(value, (int, float)):
84
+ try:
85
+ return _dt.datetime.fromtimestamp(float(value), tz=_dt.timezone.utc)
86
+ except Exception:
87
+ return None
88
+ text = str(value).strip()
89
+ if not text:
90
+ return None
91
+ if text.replace(".", "", 1).isdigit():
92
+ try:
93
+ return _dt.datetime.fromtimestamp(float(text), tz=_dt.timezone.utc)
94
+ except Exception:
95
+ return None
96
+ for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
97
+ try:
98
+ parsed = _dt.datetime.strptime(text[:19] if fmt != "%Y-%m-%d" else text[:10], fmt)
99
+ return parsed.replace(tzinfo=_dt.timezone.utc)
100
+ except Exception:
101
+ continue
102
+ try:
103
+ return _dt.datetime.fromisoformat(text.replace("Z", "+00:00"))
104
+ except Exception:
105
+ return None
106
+
107
+
108
+ def _age_days(value: Any) -> float:
109
+ parsed = _parse_time(value)
110
+ if not parsed:
111
+ return 0.0
112
+ return max(0.0, (_dt.datetime.now(_dt.timezone.utc) - parsed).total_seconds() / 86400)
113
+
114
+
115
+ def _deadline_urgency(value: Any, default: float = 0.35) -> float:
116
+ parsed = _parse_time(value)
117
+ if not parsed:
118
+ return default
119
+ delta_days = (parsed - _dt.datetime.now(_dt.timezone.utc)).total_seconds() / 86400
120
+ if delta_days <= 0:
121
+ return 1.0
122
+ if delta_days <= 1:
123
+ return 0.85
124
+ if delta_days <= 3:
125
+ return 0.7
126
+ if delta_days <= 7:
127
+ return 0.55
128
+ return 0.3
129
+
130
+
131
+ def _priority(impact: float, urgency: float, risk: float, confidence: float = 0.8) -> float:
132
+ score = (impact * 0.45) + (urgency * 0.35) + (confidence * 0.15) - (risk * 0.1)
133
+ return round(max(0.0, min(1.0, score)), 4)
134
+
135
+
136
+ def _candidate(
137
+ *,
138
+ source_primary: str,
139
+ source_key: str,
140
+ kind: str,
141
+ title: str,
142
+ summary: str = "",
143
+ state: str = "open",
144
+ impact: float = 0.6,
145
+ urgency: float = 0.4,
146
+ risk: float = 0.15,
147
+ confidence: float = 0.8,
148
+ safety_class: str = "normal",
149
+ capability_required: str = "",
150
+ capability_status: str = "unknown",
151
+ next_action: str = "",
152
+ blocker_reason: str = "",
153
+ evidence_required: str = "",
154
+ deadline_at: str = "",
155
+ source_payload: dict[str, Any] | None = None,
156
+ ) -> dict[str, Any]:
157
+ clean_source = str(source_primary)
158
+ clean_key = str(source_key)
159
+ dedupe_key = f"{clean_source}:{kind}:{clean_key}"
160
+ now = _now_iso()
161
+ return {
162
+ "id": _hash_id("CI", dedupe_key),
163
+ "title": str(title or clean_key)[:240],
164
+ "summary": str(summary or "")[:1200],
165
+ "kind": kind,
166
+ "state": state if state in OPEN_STATES else "open",
167
+ "source_primary": clean_source,
168
+ "source_key": clean_key,
169
+ "dedupe_key": dedupe_key,
170
+ "impact_score": round(impact, 4),
171
+ "urgency_score": round(urgency, 4),
172
+ "risk_score": round(risk, 4),
173
+ "confidence_score": round(confidence, 4),
174
+ "priority_score": _priority(impact, urgency, risk, confidence),
175
+ "safety_class": safety_class,
176
+ "capability_required": capability_required,
177
+ "capability_status": capability_status,
178
+ "owner": "nero",
179
+ "next_action": next_action,
180
+ "blocker_reason": blocker_reason,
181
+ "evidence_required": evidence_required,
182
+ "deadline_at": str(deadline_at or ""),
183
+ "first_seen_at": now,
184
+ "last_seen_at": now,
185
+ "source_payload_json": _safe_json(source_payload or {}),
186
+ }
187
+
188
+
189
+ def _upsert_candidate(conn, item: dict[str, Any]) -> bool:
190
+ existing = conn.execute(
191
+ "SELECT id, state FROM closure_items WHERE dedupe_key = ?",
192
+ (item["dedupe_key"],),
193
+ ).fetchone()
194
+ was_new = existing is None
195
+ conn.execute(
196
+ """
197
+ INSERT INTO closure_items (
198
+ id, title, summary, kind, state, source_primary, source_key,
199
+ dedupe_key, impact_score, urgency_score, risk_score,
200
+ confidence_score, priority_score, safety_class,
201
+ capability_required, capability_status, owner, next_action,
202
+ blocker_reason, evidence_required, deadline_at,
203
+ first_seen_at, last_seen_at, source_payload_json, updated_at
204
+ ) VALUES (
205
+ :id, :title, :summary, :kind, :state, :source_primary, :source_key,
206
+ :dedupe_key, :impact_score, :urgency_score, :risk_score,
207
+ :confidence_score, :priority_score, :safety_class,
208
+ :capability_required, :capability_status, :owner, :next_action,
209
+ :blocker_reason, :evidence_required, :deadline_at,
210
+ :first_seen_at, :last_seen_at, :source_payload_json, :last_seen_at
211
+ )
212
+ ON CONFLICT(dedupe_key) DO UPDATE SET
213
+ title = excluded.title,
214
+ summary = excluded.summary,
215
+ kind = excluded.kind,
216
+ source_primary = excluded.source_primary,
217
+ source_key = excluded.source_key,
218
+ impact_score = excluded.impact_score,
219
+ urgency_score = excluded.urgency_score,
220
+ risk_score = excluded.risk_score,
221
+ confidence_score = excluded.confidence_score,
222
+ priority_score = excluded.priority_score,
223
+ safety_class = excluded.safety_class,
224
+ capability_required = excluded.capability_required,
225
+ capability_status = excluded.capability_status,
226
+ next_action = excluded.next_action,
227
+ blocker_reason = excluded.blocker_reason,
228
+ evidence_required = excluded.evidence_required,
229
+ deadline_at = excluded.deadline_at,
230
+ last_seen_at = excluded.last_seen_at,
231
+ source_payload_json = excluded.source_payload_json,
232
+ updated_at = excluded.last_seen_at
233
+ WHERE closure_items.state NOT IN ('closed', 'rejected', 'stale')
234
+ """,
235
+ item,
236
+ )
237
+ source_id = _hash_id("CIS", f"{item['id']}:{item['source_primary']}:{item['source_key']}", 20)
238
+ conn.execute(
239
+ """
240
+ INSERT OR REPLACE INTO closure_item_sources (
241
+ id, closure_item_id, source_type, source_id, source_status,
242
+ source_payload_json, observed_at
243
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
244
+ """,
245
+ (
246
+ source_id,
247
+ item["id"],
248
+ item["source_primary"],
249
+ item["source_key"],
250
+ item["state"],
251
+ item["source_payload_json"],
252
+ item["last_seen_at"],
253
+ ),
254
+ )
255
+ if was_new:
256
+ _record_event(conn, item["id"], "discovered", "", item["state"], "Closure item discovered from source.")
257
+ return was_new
258
+
259
+
260
+ def _record_event(conn, item_id: str, event_type: str, from_state: str, to_state: str, note: str, evidence: str = "") -> None:
261
+ event_id = _hash_id("CIE", f"{item_id}:{event_type}:{from_state}:{to_state}:{_time.time_ns()}:{note}", 24)
262
+ conn.execute(
263
+ """
264
+ INSERT INTO closure_item_events (
265
+ id, closure_item_id, event_type, from_state, to_state, note, evidence, actor
266
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'nexo')
267
+ """,
268
+ (event_id, item_id, event_type, from_state, to_state, note, evidence),
269
+ )
270
+
271
+
272
+ def _readiness_counts(conn) -> dict[str, int]:
273
+ if not _table_exists(conn, "closure_capability_readiness"):
274
+ return {}
275
+ return {
276
+ row["status"]: row["n"]
277
+ for row in conn.execute(
278
+ "SELECT status, COUNT(*) AS n FROM closure_capability_readiness GROUP BY status"
279
+ ).fetchall()
280
+ }
281
+
282
+
283
+ def _protocol_task_candidates(conn, limit: int) -> list[dict[str, Any]]:
284
+ if not _table_exists(conn, "protocol_tasks"):
285
+ return []
286
+ rows = conn.execute(
287
+ """
288
+ SELECT task_id, session_id, goal, task_type, area, status, opened_at,
289
+ close_evidence, outcome_notes, verification_step
290
+ FROM protocol_tasks
291
+ WHERE lower(status) IN ('open', 'partial', 'blocked')
292
+ ORDER BY opened_at DESC
293
+ LIMIT ?
294
+ """,
295
+ (limit,),
296
+ ).fetchall()
297
+ items: list[dict[str, Any]] = []
298
+ for raw in rows:
299
+ row = _as_dict(raw)
300
+ status = str(row.get("status") or "open").lower()
301
+ state = "waiting" if status == "blocked" else "open"
302
+ urgency = min(1.0, 0.35 + (_age_days(row.get("opened_at")) / 14.0))
303
+ kind = f"protocol_task_{status}"
304
+ next_action = "Finish the task and close it with concrete evidence."
305
+ if status == "partial":
306
+ next_action = "Supply missing verification or convert the residual work into an explicit followup."
307
+ elif status == "blocked":
308
+ next_action = "Resolve the blocker or get an operator decision before continuing."
309
+ items.append(_candidate(
310
+ source_primary="protocol_tasks",
311
+ source_key=str(row.get("task_id") or ""),
312
+ kind=kind,
313
+ title=str(row.get("goal") or row.get("task_id") or "Open protocol task"),
314
+ summary=str(row.get("outcome_notes") or row.get("verification_step") or ""),
315
+ state=state,
316
+ impact=0.75,
317
+ urgency=urgency,
318
+ risk=0.15,
319
+ confidence=0.9,
320
+ next_action=next_action,
321
+ blocker_reason="status=blocked" if status == "blocked" else "",
322
+ evidence_required=str(row.get("verification_step") or "Evidence before closure."),
323
+ source_payload={
324
+ "status": row.get("status"),
325
+ "session_id": row.get("session_id"),
326
+ "task_type": row.get("task_type"),
327
+ "area": row.get("area"),
328
+ },
329
+ ))
330
+ return items
331
+
332
+
333
+ def _followup_candidates(conn, limit: int) -> list[dict[str, Any]]:
334
+ if not _table_exists(conn, "followups"):
335
+ return []
336
+ rows = conn.execute(
337
+ """
338
+ SELECT id, date, description, verification, status, impact_score, created_at, updated_at
339
+ FROM followups
340
+ WHERE lower(status) NOT IN ('done', 'complete', 'completed', 'deleted', 'archived', 'cancelled', 'canceled')
341
+ ORDER BY COALESCE(date, ''), updated_at DESC
342
+ LIMIT ?
343
+ """,
344
+ (limit,),
345
+ ).fetchall()
346
+ items: list[dict[str, Any]] = []
347
+ for raw in rows:
348
+ row = _as_dict(raw)
349
+ due = row.get("date") or ""
350
+ urgency = _deadline_urgency(due, default=0.45)
351
+ overdue = bool(_parse_time(due) and urgency >= 1.0)
352
+ kind = "followup_due" if overdue else "followup_pending"
353
+ items.append(_candidate(
354
+ source_primary="followups",
355
+ source_key=str(row.get("id") or ""),
356
+ kind=kind,
357
+ title=str(row.get("description") or row.get("id") or "Pending followup"),
358
+ state="open",
359
+ impact=max(0.45, min(1.0, float(row.get("impact_score") or 0) / 100.0 if row.get("impact_score") else 0.55)),
360
+ urgency=urgency,
361
+ risk=0.1,
362
+ confidence=0.85,
363
+ next_action="Complete, update, or explicitly reschedule this followup.",
364
+ evidence_required=str(row.get("verification") or "Result note or explicit reschedule evidence."),
365
+ deadline_at=str(due or ""),
366
+ source_payload={"status": row.get("status"), "date": due},
367
+ ))
368
+ return items
369
+
370
+
371
+ def _protocol_debt_candidates(conn, limit: int) -> list[dict[str, Any]]:
372
+ if not _table_exists(conn, "protocol_debt"):
373
+ return []
374
+ rows = conn.execute(
375
+ """
376
+ SELECT id, session_id, task_id, debt_type, severity, status, evidence, created_at
377
+ FROM protocol_debt
378
+ WHERE lower(status) = 'open'
379
+ ORDER BY created_at DESC
380
+ LIMIT ?
381
+ """,
382
+ (limit,),
383
+ ).fetchall()
384
+ items: list[dict[str, Any]] = []
385
+ severity_impact = {"block": 0.85, "error": 0.8, "warn": 0.6, "info": 0.35}
386
+ for raw in rows:
387
+ row = _as_dict(raw)
388
+ severity = str(row.get("severity") or "warn").lower()
389
+ impact = severity_impact.get(severity, 0.6)
390
+ items.append(_candidate(
391
+ source_primary="protocol_debt",
392
+ source_key=str(row.get("id") or ""),
393
+ kind="protocol_debt_open",
394
+ title=f"Open protocol debt: {row.get('debt_type') or row.get('id')}",
395
+ summary=str(row.get("evidence") or ""),
396
+ state="waiting" if severity in {"block", "error"} else "open",
397
+ impact=impact,
398
+ urgency=min(1.0, 0.4 + (_age_days(row.get("created_at")) / 21.0)),
399
+ risk=0.2,
400
+ confidence=0.9,
401
+ next_action="Resolve the debt with evidence or mark it as intentionally superseded.",
402
+ blocker_reason="blocking protocol debt" if severity in {"block", "error"} else "",
403
+ evidence_required="Resolution note tied to the source task/session.",
404
+ source_payload={
405
+ "severity": severity,
406
+ "task_id": row.get("task_id"),
407
+ "session_id": row.get("session_id"),
408
+ },
409
+ ))
410
+ return items
411
+
412
+
413
+ def _outcome_candidates(conn, limit: int) -> list[dict[str, Any]]:
414
+ if not _table_exists(conn, "outcomes"):
415
+ return []
416
+ rows = conn.execute(
417
+ """
418
+ SELECT id, action_type, action_id, session_id, description, expected_result,
419
+ status, deadline, checked_at, notes
420
+ FROM outcomes
421
+ WHERE lower(status) IN ('pending', 'missed', 'failed')
422
+ ORDER BY deadline ASC
423
+ LIMIT ?
424
+ """,
425
+ (limit,),
426
+ ).fetchall()
427
+ items: list[dict[str, Any]] = []
428
+ for raw in rows:
429
+ row = _as_dict(raw)
430
+ status = str(row.get("status") or "pending").lower()
431
+ items.append(_candidate(
432
+ source_primary="outcomes",
433
+ source_key=str(row.get("id") or ""),
434
+ kind=f"outcome_{status}",
435
+ title=str(row.get("description") or row.get("expected_result") or f"Outcome {row.get('id')}"),
436
+ summary=str(row.get("notes") or row.get("expected_result") or ""),
437
+ state="open",
438
+ impact=0.7 if status == "pending" else 0.8,
439
+ urgency=_deadline_urgency(row.get("deadline"), default=0.45),
440
+ risk=0.15,
441
+ confidence=0.85,
442
+ next_action="Verify the expected result and record the outcome.",
443
+ evidence_required=str(row.get("expected_result") or "Measured result or explicit miss reason."),
444
+ deadline_at=str(row.get("deadline") or ""),
445
+ source_payload={
446
+ "status": status,
447
+ "action_type": row.get("action_type"),
448
+ "action_id": row.get("action_id"),
449
+ "session_id": row.get("session_id"),
450
+ },
451
+ ))
452
+ return items
453
+
454
+
455
+ def _mcp_write_queue_candidates(limit: int) -> list[dict[str, Any]]:
456
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
457
+ root = nexo_home / "runtime" / "operations" / "mcp-write-queue"
458
+ if not root.exists():
459
+ return []
460
+ items: list[dict[str, Any]] = []
461
+ for state in ("dead_letter", "failed", "retrying", "queued"):
462
+ for path in sorted((root / state).glob("*.json"))[: max(1, limit)]:
463
+ if len(items) >= limit:
464
+ return items
465
+ try:
466
+ payload = json.loads(path.read_text(encoding="utf-8"))
467
+ except Exception:
468
+ continue
469
+ write_id = str(payload.get("writeId") or path.stem)
470
+ status = str(payload.get("status") or state)
471
+ created = payload.get("created_at")
472
+ items.append(_candidate(
473
+ source_primary="mcp_write_queue",
474
+ source_key=write_id,
475
+ kind=f"mcp_write_queue_{status}",
476
+ title=f"MCP write queue item {write_id}",
477
+ summary=str(payload.get("kind") or ""),
478
+ state="waiting" if status in {"failed", "dead_letter"} else "open",
479
+ impact=0.65,
480
+ urgency=min(1.0, 0.35 + (_age_days(created) / 3.0)),
481
+ risk=0.2,
482
+ confidence=0.8,
483
+ next_action="Inspect the queued write and let the queue worker retry or resolve it manually.",
484
+ blocker_reason=status if status in {"failed", "dead_letter"} else "",
485
+ evidence_required="Committed queue status or explicit dead-letter resolution.",
486
+ source_payload={
487
+ "status": status,
488
+ "kind": payload.get("kind"),
489
+ "attempts": payload.get("attempts"),
490
+ "last_error": str(payload.get("last_error") or "")[:240],
491
+ },
492
+ ))
493
+ return items
494
+
495
+
496
+ def refresh_closure_items(conn=None, *, limit_per_adapter: int = 250) -> dict[str, Any]:
497
+ if conn is None:
498
+ from db import get_db
499
+ from db._schema import run_migrations
500
+
501
+ conn = get_db()
502
+ run_migrations(conn)
503
+ candidates: list[dict[str, Any]] = []
504
+ adapter_counts: dict[str, int] = {}
505
+ adapter_errors: dict[str, str] = {}
506
+ adapters = [
507
+ ("protocol_tasks", lambda: _protocol_task_candidates(conn, limit_per_adapter)),
508
+ ("followups", lambda: _followup_candidates(conn, limit_per_adapter)),
509
+ ("protocol_debt", lambda: _protocol_debt_candidates(conn, limit_per_adapter)),
510
+ ("outcomes", lambda: _outcome_candidates(conn, limit_per_adapter)),
511
+ ("mcp_write_queue", lambda: _mcp_write_queue_candidates(limit_per_adapter)),
512
+ ]
513
+ for name, adapter in adapters:
514
+ try:
515
+ produced = adapter()
516
+ except Exception as exc:
517
+ produced = []
518
+ adapter_errors[name] = f"{type(exc).__name__}: {exc}"
519
+ adapter_counts[name] = len(produced)
520
+ candidates.extend(produced)
521
+
522
+ created = 0
523
+ for item in candidates:
524
+ if _upsert_candidate(conn, item):
525
+ created += 1
526
+ conn.commit()
527
+ _write_daily_snapshot(conn)
528
+ return {
529
+ "ok": True,
530
+ "adapters": adapter_counts,
531
+ "adapter_errors": adapter_errors,
532
+ "observed": len(candidates),
533
+ "created": created,
534
+ }
535
+
536
+
537
+ def closure_next(
538
+ conn=None,
539
+ *,
540
+ limit: int = 10,
541
+ include_waiting: bool = False,
542
+ source: str = "",
543
+ kind: str = "",
544
+ state: str = "",
545
+ max_risk: float | None = None,
546
+ area: str = "",
547
+ ) -> list[dict[str, Any]]:
548
+ if conn is None:
549
+ from db import get_db
550
+
551
+ conn = get_db()
552
+ clean_state = _canonical_state(state)
553
+ if clean_state:
554
+ if clean_state not in OPEN_STATES | FINAL_STATES:
555
+ return []
556
+ states = (clean_state,)
557
+ else:
558
+ states = ("open", "verified", "waiting") if include_waiting else ("open", "verified")
559
+ clauses = [f"state IN ({','.join('?' for _ in states)})"]
560
+ params: list[Any] = list(states)
561
+ if source:
562
+ clauses.append("source_primary = ?")
563
+ params.append(source)
564
+ if kind:
565
+ clauses.append("kind = ?")
566
+ params.append(kind)
567
+ if max_risk is not None:
568
+ try:
569
+ risk_limit = float(max_risk)
570
+ except Exception:
571
+ risk_limit = 0.0
572
+ if risk_limit > 0:
573
+ clauses.append("risk_score <= ?")
574
+ params.append(max(0.0, min(risk_limit, 1.0)))
575
+ clean_area = str(area or "").strip()
576
+ if clean_area:
577
+ like_area = f"%{clean_area}%"
578
+ clauses.append(
579
+ "("
580
+ "source_primary = ? OR kind = ? OR owner = ? OR capability_required = ? "
581
+ "OR title LIKE ? OR summary LIKE ? OR source_payload_json LIKE ?"
582
+ ")"
583
+ )
584
+ params.extend([clean_area, clean_area, clean_area, clean_area, like_area, like_area, like_area])
585
+ params.append(max(1, min(int(limit or 10), 100)))
586
+ rows = conn.execute(
587
+ f"""
588
+ SELECT *
589
+ FROM closure_items
590
+ WHERE {' AND '.join(clauses)}
591
+ ORDER BY priority_score DESC, urgency_score DESC, updated_at DESC
592
+ LIMIT ?
593
+ """,
594
+ params,
595
+ ).fetchall()
596
+ return [_as_dict(row) for row in rows]
597
+
598
+
599
+ def closure_status(conn=None, *, refresh: bool = True, limit: int = 10) -> dict[str, Any]:
600
+ if conn is None:
601
+ from db import get_db
602
+ from db._schema import run_migrations
603
+
604
+ conn = get_db()
605
+ run_migrations(conn)
606
+ refresh_result = refresh_closure_items(conn, limit_per_adapter=250) if refresh else {"ok": True}
607
+ counts = {
608
+ row["state"]: row["n"]
609
+ for row in conn.execute("SELECT state, COUNT(*) AS n FROM closure_items GROUP BY state").fetchall()
610
+ }
611
+ by_kind = {
612
+ row["kind"]: row["n"]
613
+ for row in conn.execute("SELECT kind, COUNT(*) AS n FROM closure_items WHERE state IN ('open', 'waiting', 'verified') GROUP BY kind").fetchall()
614
+ }
615
+ return {
616
+ "ok": True,
617
+ "schema": "nexo.closure.status.v1",
618
+ "refreshed": refresh_result,
619
+ "counts": counts,
620
+ "capability_readiness": _readiness_counts(conn),
621
+ "open_total": sum(int(counts.get(state, 0)) for state in OPEN_STATES),
622
+ "by_kind": by_kind,
623
+ "next": closure_next(conn, limit=limit, include_waiting=True),
624
+ }
625
+
626
+
627
+ def closure_item_get(item_id: str, conn=None) -> dict[str, Any] | None:
628
+ if conn is None:
629
+ from db import get_db
630
+
631
+ conn = get_db()
632
+ clean_id = str(item_id or "").strip()
633
+ if not clean_id:
634
+ return None
635
+ item = conn.execute(
636
+ "SELECT * FROM closure_items WHERE id = ? OR dedupe_key = ?",
637
+ (clean_id, clean_id),
638
+ ).fetchone()
639
+ if not item:
640
+ return None
641
+ payload = _as_dict(item)
642
+ sources = conn.execute(
643
+ "SELECT * FROM closure_item_sources WHERE closure_item_id = ? ORDER BY observed_at DESC",
644
+ (payload["id"],),
645
+ ).fetchall()
646
+ events = conn.execute(
647
+ "SELECT * FROM closure_item_events WHERE closure_item_id = ? ORDER BY created_at DESC LIMIT 50",
648
+ (payload["id"],),
649
+ ).fetchall()
650
+ links = conn.execute(
651
+ "SELECT * FROM closure_item_links WHERE closure_item_id = ? ORDER BY created_at DESC, link_type, link_id",
652
+ (payload["id"],),
653
+ ).fetchall() if _table_exists(conn, "closure_item_links") else []
654
+ payload["sources"] = [_as_dict(row) for row in sources]
655
+ payload["events"] = [_as_dict(row) for row in events]
656
+ payload["links"] = [_as_dict(row) for row in links]
657
+ return payload
658
+
659
+
660
+ def closure_verify_item(item_id: str, evidence: str, conn=None) -> dict[str, Any]:
661
+ if conn is None:
662
+ from db import get_db
663
+
664
+ conn = get_db()
665
+ clean_evidence = str(evidence or "").strip()
666
+ if not clean_evidence:
667
+ return {"ok": False, "error": "evidence is required"}
668
+ item = closure_item_get(item_id, conn)
669
+ if not item:
670
+ return {"ok": False, "error": "closure item not found"}
671
+ if item["state"] in FINAL_STATES:
672
+ return {"ok": False, "error": f"closure item is already {item['state']}"}
673
+ now = _now_iso()
674
+ conn.execute(
675
+ """
676
+ UPDATE closure_items
677
+ SET state = 'verified',
678
+ evidence_observed = ?,
679
+ last_progress_at = ?,
680
+ updated_at = ?
681
+ WHERE id = ?
682
+ """,
683
+ (clean_evidence, now, now, item["id"]),
684
+ )
685
+ _record_event(conn, item["id"], "verified", item["state"], "verified", "Closure evidence recorded.", clean_evidence)
686
+ conn.commit()
687
+ return {"ok": True, "id": item["id"], "state": "verified"}
688
+
689
+
690
+ def closure_close_item(item_id: str, *, reason: str = "completed", conn=None) -> dict[str, Any]:
691
+ if conn is None:
692
+ from db import get_db
693
+
694
+ conn = get_db()
695
+ item = closure_item_get(item_id, conn)
696
+ if not item:
697
+ return {"ok": False, "error": "closure item not found"}
698
+ if item["state"] in FINAL_STATES:
699
+ return {"ok": True, "id": item["id"], "state": item["state"], "already_final": True}
700
+ if not str(item.get("evidence_observed") or "").strip() and str(reason or "").strip() not in {"rejected", "stale"}:
701
+ return {"ok": False, "error": "verification evidence is required before close"}
702
+ final_state = "rejected" if str(reason or "").strip() == "rejected" else "stale" if str(reason or "").strip() == "stale" else "closed"
703
+ now = _now_iso()
704
+ conn.execute(
705
+ """
706
+ UPDATE closure_items
707
+ SET state = ?,
708
+ closed_at = ?,
709
+ close_reason = ?,
710
+ updated_at = ?
711
+ WHERE id = ?
712
+ """,
713
+ (final_state, now, str(reason or final_state), now, item["id"]),
714
+ )
715
+ _record_event(conn, item["id"], "closed", item["state"], final_state, str(reason or final_state), item.get("evidence_observed") or "")
716
+ conn.commit()
717
+ return {"ok": True, "id": item["id"], "state": final_state}
718
+
719
+
720
+ def closure_link_item(
721
+ item_id: str,
722
+ *,
723
+ link_type: str,
724
+ link_id: str,
725
+ relation: str = "related",
726
+ conn=None,
727
+ ) -> dict[str, Any]:
728
+ if conn is None:
729
+ from db import get_db
730
+
731
+ conn = get_db()
732
+ item = closure_item_get(item_id, conn)
733
+ if not item:
734
+ return {"ok": False, "error": "closure item not found"}
735
+ clean_type = str(link_type or "").strip()
736
+ clean_id = str(link_id or "").strip()
737
+ clean_relation = str(relation or "related").strip() or "related"
738
+ if not clean_type or not clean_id:
739
+ return {"ok": False, "error": "link_type and link_id are required"}
740
+ now = _now_iso()
741
+ link_pk = _hash_id("CIL", f"{item['id']}:{clean_type}:{clean_id}:{clean_relation}", 24)
742
+ conn.execute(
743
+ """
744
+ INSERT INTO closure_item_links (
745
+ id, closure_item_id, link_type, link_id, relation, created_at
746
+ ) VALUES (?, ?, ?, ?, ?, ?)
747
+ ON CONFLICT(closure_item_id, link_type, link_id, relation) DO UPDATE SET
748
+ created_at = excluded.created_at
749
+ """,
750
+ (link_pk, item["id"], clean_type, clean_id, clean_relation, now),
751
+ )
752
+ _record_event(
753
+ conn,
754
+ item["id"],
755
+ "linked",
756
+ item["state"],
757
+ item["state"],
758
+ f"Linked {clean_type}:{clean_id} as {clean_relation}.",
759
+ )
760
+ conn.commit()
761
+ return {
762
+ "ok": True,
763
+ "id": link_pk,
764
+ "closure_item_id": item["id"],
765
+ "link_type": clean_type,
766
+ "link_id": clean_id,
767
+ "relation": clean_relation,
768
+ }
769
+
770
+
771
+ def closure_set_capability_readiness(
772
+ capability: str,
773
+ *,
774
+ status: str = "unknown",
775
+ reason: str = "",
776
+ evidence: str = "",
777
+ expires_at: str = "",
778
+ conn=None,
779
+ ) -> dict[str, Any]:
780
+ if conn is None:
781
+ from db import get_db
782
+
783
+ conn = get_db()
784
+ clean_capability = str(capability or "").strip()
785
+ clean_status = str(status or "unknown").strip()
786
+ if not clean_capability:
787
+ return {"ok": False, "error": "capability is required"}
788
+ if clean_status not in READINESS_STATES:
789
+ return {"ok": False, "error": f"invalid readiness status: {clean_status}"}
790
+ now = _now_iso()
791
+ row_id = _hash_id("CCR", clean_capability, 20)
792
+ conn.execute(
793
+ """
794
+ INSERT INTO closure_capability_readiness (
795
+ id, capability, status, reason, verified_at, verification_evidence, expires_at
796
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
797
+ ON CONFLICT(capability) DO UPDATE SET
798
+ status = excluded.status,
799
+ reason = excluded.reason,
800
+ verified_at = excluded.verified_at,
801
+ verification_evidence = excluded.verification_evidence,
802
+ expires_at = excluded.expires_at
803
+ """,
804
+ (row_id, clean_capability, clean_status, str(reason or ""), now, str(evidence or ""), str(expires_at or "")),
805
+ )
806
+ conn.commit()
807
+ return {"ok": True, "id": row_id, "capability": clean_capability, "status": clean_status}
808
+
809
+
810
+ def closure_triage_item(
811
+ item_id: str,
812
+ *,
813
+ state: str = "",
814
+ kind: str = "",
815
+ blocker_reason: str = "",
816
+ next_action: str = "",
817
+ evidence_required: str = "",
818
+ owner: str = "",
819
+ capability_required: str = "",
820
+ capability_status: str = "",
821
+ duplicate_of: str = "",
822
+ conn=None,
823
+ ) -> dict[str, Any]:
824
+ if conn is None:
825
+ from db import get_db
826
+
827
+ conn = get_db()
828
+ item = closure_item_get(item_id, conn)
829
+ if not item:
830
+ return {"ok": False, "error": "closure item not found"}
831
+ updates: dict[str, Any] = {}
832
+ requested_state = str(state or "").strip()
833
+ clean_state = _canonical_state(requested_state)
834
+ if clean_state:
835
+ if clean_state not in TRIAGE_STATES:
836
+ return {"ok": False, "error": f"invalid state: {requested_state}"}
837
+ updates["state"] = clean_state
838
+ for column, value in (
839
+ ("kind", kind),
840
+ ("blocker_reason", blocker_reason),
841
+ ("next_action", next_action),
842
+ ("evidence_required", evidence_required),
843
+ ("owner", owner),
844
+ ("capability_required", capability_required),
845
+ ("capability_status", capability_status),
846
+ ):
847
+ clean_value = str(value or "").strip()
848
+ if clean_value:
849
+ updates[column] = clean_value
850
+ clean_duplicate = str(duplicate_of or "").strip()
851
+ if clean_duplicate:
852
+ target = closure_item_get(clean_duplicate, conn)
853
+ if not target:
854
+ return {"ok": False, "error": "duplicate target not found"}
855
+ closure_link_item(item["id"], link_type="closure_item", link_id=target["id"], relation="duplicate_of", conn=conn)
856
+ updates["state"] = "stale"
857
+ updates["close_reason"] = f"duplicate_of:{target['id']}"
858
+ updates["closed_at"] = _now_iso()
859
+ if not updates:
860
+ return {"ok": False, "error": "no triage changes supplied"}
861
+ now = _now_iso()
862
+ updates["updated_at"] = now
863
+ if set(updates) - {"updated_at"}:
864
+ updates["last_progress_at"] = now
865
+ assignments = ", ".join(f"{column} = ?" for column in updates)
866
+ params = list(updates.values()) + [item["id"]]
867
+ conn.execute(f"UPDATE closure_items SET {assignments} WHERE id = ?", params)
868
+ to_state = updates.get("state", item["state"])
869
+ _record_event(
870
+ conn,
871
+ item["id"],
872
+ "triaged",
873
+ item["state"],
874
+ to_state,
875
+ "Closure item triaged: " + ", ".join(sorted(column for column in updates if column != "updated_at")),
876
+ )
877
+ conn.commit()
878
+ result = {"ok": True, "id": item["id"], "state": to_state, "updated": sorted(updates)}
879
+ if requested_state and requested_state != clean_state:
880
+ result["requested_state"] = requested_state
881
+ return result
882
+
883
+
884
+ def closure_snapshot(conn=None, *, refresh: bool = True, snapshot_date: str = "", limit: int = 10) -> dict[str, Any]:
885
+ if conn is None:
886
+ from db import get_db
887
+ from db._schema import run_migrations
888
+
889
+ conn = get_db()
890
+ run_migrations(conn)
891
+ if refresh:
892
+ refresh_closure_items(conn)
893
+ date_key = str(snapshot_date or _today()).strip()[:10]
894
+ if date_key != _today():
895
+ _write_daily_snapshot_for_date(conn, snapshot_date=date_key, limit=limit)
896
+ else:
897
+ _write_daily_snapshot(conn, limit=limit)
898
+ row = conn.execute(
899
+ "SELECT * FROM closure_daily_snapshots WHERE snapshot_date = ?",
900
+ (date_key,),
901
+ ).fetchone()
902
+ payload = _as_dict(row) if row else {}
903
+ if payload.get("top_items_json"):
904
+ try:
905
+ payload["top_items"] = json.loads(payload["top_items_json"])
906
+ except Exception:
907
+ payload["top_items"] = []
908
+ payload["capability_readiness"] = _readiness_counts(conn)
909
+ return {"ok": bool(payload), "snapshot": payload}
910
+
911
+
912
+ def _write_daily_snapshot_for_date(conn, *, snapshot_date: str, limit: int = 10) -> None:
913
+ counts = {
914
+ row["state"]: row["n"]
915
+ for row in conn.execute("SELECT state, COUNT(*) AS n FROM closure_items GROUP BY state").fetchall()
916
+ }
917
+ top = closure_next(conn, limit=limit, include_waiting=True)
918
+ conn.execute(
919
+ """
920
+ INSERT OR REPLACE INTO closure_daily_snapshots (
921
+ snapshot_date, total_open, total_verified, total_waiting, total_closed,
922
+ top_items_json, created_at
923
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
924
+ """,
925
+ (
926
+ snapshot_date,
927
+ int(counts.get("open", 0)),
928
+ int(counts.get("verified", 0)),
929
+ int(counts.get("waiting", 0)),
930
+ int(counts.get("closed", 0)),
931
+ _safe_json([
932
+ {
933
+ "id": item.get("id"),
934
+ "title": item.get("title"),
935
+ "priority_score": item.get("priority_score"),
936
+ "state": item.get("state"),
937
+ }
938
+ for item in top
939
+ ]),
940
+ _now_iso(),
941
+ ),
942
+ )
943
+ conn.commit()
944
+
945
+
946
+ def _write_daily_snapshot(conn, *, limit: int = 10) -> None:
947
+ counts = {
948
+ row["state"]: row["n"]
949
+ for row in conn.execute("SELECT state, COUNT(*) AS n FROM closure_items GROUP BY state").fetchall()
950
+ }
951
+ top = closure_next(conn, limit=limit, include_waiting=True)
952
+ conn.execute(
953
+ """
954
+ INSERT OR REPLACE INTO closure_daily_snapshots (
955
+ snapshot_date, total_open, total_verified, total_waiting, total_closed,
956
+ top_items_json, created_at
957
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
958
+ """,
959
+ (
960
+ _today(),
961
+ int(counts.get("open", 0)),
962
+ int(counts.get("verified", 0)),
963
+ int(counts.get("waiting", 0)),
964
+ int(counts.get("closed", 0)),
965
+ _safe_json([
966
+ {
967
+ "id": item.get("id"),
968
+ "title": item.get("title"),
969
+ "priority_score": item.get("priority_score"),
970
+ "state": item.get("state"),
971
+ }
972
+ for item in top
973
+ ]),
974
+ _now_iso(),
975
+ ),
976
+ )
977
+ conn.commit()
978
+
979
+
980
+ def handle_closure_status(refresh: bool = True, limit: int = 10) -> str:
981
+ return json.dumps(closure_status(refresh=refresh, limit=limit), indent=2, ensure_ascii=False)
982
+
983
+
984
+ def handle_closure_next(
985
+ limit: int = 10,
986
+ include_waiting: bool = False,
987
+ source: str = "",
988
+ kind: str = "",
989
+ state: str = "",
990
+ max_risk: float | None = None,
991
+ area: str = "",
992
+ ) -> str:
993
+ from db import get_db
994
+ from db._schema import run_migrations
995
+
996
+ conn = get_db()
997
+ run_migrations(conn)
998
+ refresh_closure_items(conn)
999
+ return json.dumps({
1000
+ "ok": True,
1001
+ "items": closure_next(
1002
+ conn,
1003
+ limit=limit,
1004
+ include_waiting=include_waiting,
1005
+ source=source,
1006
+ kind=kind,
1007
+ state=state,
1008
+ max_risk=max_risk,
1009
+ area=area,
1010
+ ),
1011
+ }, indent=2, ensure_ascii=False)
1012
+
1013
+
1014
+ def handle_closure_item_get(item_id: str) -> str:
1015
+ from db import get_db
1016
+ from db._schema import run_migrations
1017
+
1018
+ conn = get_db()
1019
+ run_migrations(conn)
1020
+ item = closure_item_get(item_id, conn)
1021
+ return json.dumps({"ok": bool(item), "item": item}, indent=2, ensure_ascii=False)
1022
+
1023
+
1024
+ def handle_closure_triage(
1025
+ item_id: str,
1026
+ state: str = "",
1027
+ kind: str = "",
1028
+ blocker_reason: str = "",
1029
+ next_action: str = "",
1030
+ evidence_required: str = "",
1031
+ owner: str = "",
1032
+ capability_required: str = "",
1033
+ capability_status: str = "",
1034
+ duplicate_of: str = "",
1035
+ ) -> str:
1036
+ from db import get_db
1037
+ from db._schema import run_migrations
1038
+
1039
+ conn = get_db()
1040
+ run_migrations(conn)
1041
+ return json.dumps(
1042
+ closure_triage_item(
1043
+ item_id,
1044
+ state=state,
1045
+ kind=kind,
1046
+ blocker_reason=blocker_reason,
1047
+ next_action=next_action,
1048
+ evidence_required=evidence_required,
1049
+ owner=owner,
1050
+ capability_required=capability_required,
1051
+ capability_status=capability_status,
1052
+ duplicate_of=duplicate_of,
1053
+ conn=conn,
1054
+ ),
1055
+ indent=2,
1056
+ ensure_ascii=False,
1057
+ )
1058
+
1059
+
1060
+ def handle_closure_link(item_id: str, link_type: str, link_id: str, relation: str = "related") -> str:
1061
+ from db import get_db
1062
+ from db._schema import run_migrations
1063
+
1064
+ conn = get_db()
1065
+ run_migrations(conn)
1066
+ return json.dumps(
1067
+ closure_link_item(item_id, link_type=link_type, link_id=link_id, relation=relation, conn=conn),
1068
+ indent=2,
1069
+ ensure_ascii=False,
1070
+ )
1071
+
1072
+
1073
+ def handle_closure_snapshot(refresh: bool = True, snapshot_date: str = "", limit: int = 10) -> str:
1074
+ from db import get_db
1075
+ from db._schema import run_migrations
1076
+
1077
+ conn = get_db()
1078
+ run_migrations(conn)
1079
+ return json.dumps(
1080
+ closure_snapshot(conn, refresh=refresh, snapshot_date=snapshot_date, limit=limit),
1081
+ indent=2,
1082
+ ensure_ascii=False,
1083
+ )
1084
+
1085
+
1086
+ def handle_closure_verify(item_id: str, evidence: str) -> str:
1087
+ from db import get_db
1088
+ from db._schema import run_migrations
1089
+
1090
+ conn = get_db()
1091
+ run_migrations(conn)
1092
+ return json.dumps(closure_verify_item(item_id, evidence, conn), indent=2, ensure_ascii=False)
1093
+
1094
+
1095
+ def handle_closure_close(item_id: str, reason: str = "completed") -> str:
1096
+ from db import get_db
1097
+ from db._schema import run_migrations
1098
+
1099
+ conn = get_db()
1100
+ run_migrations(conn)
1101
+ return json.dumps(closure_close_item(item_id, reason=reason, conn=conn), indent=2, ensure_ascii=False)