cctally 1.36.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.37.0] - 2026-06-11
9
+
10
+ ### Added
11
+ - Conversation viewer: Claude Code's live to-do tools (`TaskCreate`/`TaskUpdate`/`TaskList`) now render as a single checklist card with a progress bar — the same card the retired `TodoWrite` used — reconstructed at read time from the create/update/delete stream so the task list evolves turn by turn, instead of separate raw JSON tool chips. Additive; the legacy `TodoWrite` path is unchanged and no database migration is required.
12
+
8
13
  ## [1.36.0] - 2026-06-11
9
14
 
10
15
  ### Added
@@ -105,6 +105,7 @@ def _normalize(obj, t, offset):
105
105
  entry_type = TOOL_RESULT
106
106
  _attach_subagent_result(blocks, obj) # #166: record-level toolUseResult
107
107
  _attach_ask_answers(blocks, obj) # #177 S2: AskUserQuestion answers
108
+ _attach_task_meta(blocks, obj) # task checklist identity
108
109
  # tool_result rows are stored but NOT indexed as prose (spec §2). A
109
110
  # user line that mixes a text block with a tool_result block must not
110
111
  # leak that text into the FTS index; the full content stays in
@@ -287,6 +288,47 @@ def _attach_ask_answers(blocks, obj):
287
288
  results[0]["ask_annotations"] = bounded_anno
288
289
 
289
290
 
291
+ def _attach_task_meta(blocks, obj):
292
+ """Stash a Task* tool's record-level identity onto its single tool_result
293
+ block so the query-kernel fold has a robust id (NOT a result-string parse).
294
+ Task ids are monotonic + never reused, so the explicit id is the only stable
295
+ fold key. Self-identifying by toolUseResult shape:
296
+ TaskCreate -> {"task": {"id": ...}} -> block["task_id"]
297
+ TaskUpdate -> {"taskId": ...} -> block["task_id"]
298
+ TaskList -> {"tasks": [{id,subject,status}]} -> block["task_list"]
299
+ Same exactly-one-result-block guard as _attach_subagent_result. Subjects
300
+ bounded through _bound_input.
301
+
302
+ The ``task.id`` (not ``task.task_id``) gate deliberately ignores the
303
+ look-alike local_bash spawn result {"task": {"task_id": ..., ...}}, which is
304
+ a different tool family and carries no checklist id."""
305
+ tur = obj.get("toolUseResult")
306
+ if not isinstance(tur, dict):
307
+ return
308
+ results = [b for b in blocks if b.get("kind") == "tool_result"]
309
+ if len(results) != 1:
310
+ return
311
+ block = results[0]
312
+ task = tur.get("task")
313
+ if isinstance(task, dict) and task.get("id") is not None:
314
+ block["task_id"] = str(task["id"])
315
+ return
316
+ if tur.get("taskId") is not None:
317
+ block["task_id"] = str(tur["taskId"])
318
+ return
319
+ tasks = tur.get("tasks")
320
+ if isinstance(tasks, list):
321
+ snap = []
322
+ for t in tasks:
323
+ if not isinstance(t, dict) or t.get("id") is None:
324
+ continue
325
+ bounded, _ = _bound_input({"subject": t.get("subject") or ""})
326
+ snap.append({"id": str(t["id"]),
327
+ "subject": bounded.get("subject", ""),
328
+ "status": t.get("status") or "pending"})
329
+ block["task_list"] = snap
330
+
331
+
290
332
  def _stringify(c):
291
333
  if isinstance(c, str):
292
334
  return c
@@ -484,6 +484,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
484
484
  spawn_kind = {} # tool_use id -> subagent_type
485
485
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
486
486
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
487
+ task_link = {} # tool_use id -> {"task_id", "task_list"} (Task* checklist)
487
488
  for it in items:
488
489
  for b in it["blocks"]:
489
490
  k = b.get("kind")
@@ -500,6 +501,10 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
500
501
  anno = b.pop("ask_annotations", None)
501
502
  if ans is not None and b.get("tool_use_id") is not None:
502
503
  ask_link[b["tool_use_id"]] = (ans, anno)
504
+ tid_ = b.pop("task_id", None) # Task* checklist
505
+ tlist_ = b.pop("task_list", None)
506
+ if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
507
+ task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
503
508
  subagent_meta = {}
504
509
  for _tuid, _kind in spawn_kind.items():
505
510
  _link = agent_link.get(_tuid)
@@ -564,6 +569,9 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
564
569
  if link[1]:
565
570
  b["annotations"] = link[1]
566
571
 
572
+ # ---- Phase 3b: fold the Task* op stream into per-run checklist snapshots ----
573
+ _fold_task_runs(items, task_link)
574
+
567
575
  # ---- Phase 4: classify injected meta items (skill / command / context) ----
568
576
  # `meta` rows (the parser's isMeta classification) AND — only while the 005
569
577
  # reingest is still pending — not-yet-reingested `human` rows whose body is a
@@ -697,6 +705,68 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
697
705
  }
698
706
 
699
707
 
708
+ _TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
709
+
710
+
711
+ def _fold_task_runs(items, task_link):
712
+ """Reconstruct the running to-do list from the chronological Task* op stream
713
+ and stamp the resulting todos[] snapshot onto the FIRST tool_call of each
714
+ Task* run. State spans the whole session; key on the explicit task id (never
715
+ reused). `deleted` drops a task; a TaskList result reseeds the whole snapshot.
716
+
717
+ Mirrors the ask_answers join: the parser stashed the record-level identity
718
+ onto the tool_result block, the Phase-1 sweep popped it into ``task_link``
719
+ keyed by tool_use_id, and this fold joins it back. The frontend stays a pure
720
+ todos[] renderer — all running-list state lives here."""
721
+ order = []
722
+ state = {}
723
+
724
+ def snapshot():
725
+ return [dict(content=state[i]["content"], status=state[i]["status"],
726
+ **({"activeForm": state[i]["activeForm"]} if state[i].get("activeForm") else {}))
727
+ for i in order if i in state]
728
+
729
+ for it in items:
730
+ if it.get("kind") != "assistant":
731
+ continue
732
+ first_task_call = None
733
+ for b in it["blocks"]:
734
+ if b.get("kind") != "tool_call" or b.get("name") not in _TASK_TRIO:
735
+ continue
736
+ if first_task_call is None:
737
+ first_task_call = b
738
+ link = task_link.get(b.get("tool_use_id")) or {}
739
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
740
+ name = b["name"]
741
+ if name == "TaskCreate":
742
+ tid = link.get("task_id")
743
+ if tid is not None:
744
+ if tid not in state:
745
+ order.append(tid)
746
+ state[tid] = {"content": inp.get("subject") or "", "status": "pending",
747
+ "activeForm": inp.get("activeForm") or ""}
748
+ elif name == "TaskUpdate":
749
+ tid = str(inp.get("taskId")) if inp.get("taskId") is not None else link.get("task_id")
750
+ status = inp.get("status")
751
+ if tid is not None:
752
+ if status == "deleted":
753
+ state.pop(tid, None)
754
+ elif tid in state and status:
755
+ state[tid]["status"] = status
756
+ elif name == "TaskList":
757
+ snap = link.get("task_list")
758
+ if snap is not None:
759
+ order = []
760
+ state = {}
761
+ for t in snap:
762
+ tid = t["id"]
763
+ order.append(tid)
764
+ state[tid] = {"content": t.get("subject") or "",
765
+ "status": t.get("status") or "pending", "activeForm": ""}
766
+ if first_task_call is not None:
767
+ first_task_call["task_snapshot"] = snapshot()
768
+
769
+
700
770
  def _latest(logical, col):
701
771
  """Most-recent non-null value in a column across the session (project/branch
702
772
  show the latest, matching the dashboard's session posture)."""