cctally 1.36.0 → 1.37.1

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,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.37.1] - 2026-06-11
9
+
10
+ ### Fixed
11
+ - Conversation viewer: the `TaskCreate`/`TaskUpdate`/`TaskList` checklist card rendered an empty "0 / 0" for tasks created inside subagents. Subagent Task tools record their result as a plain string (`Task #N created successfully: …`) rather than the structured shape main-session tools use, so the reader couldn't recover the task ids; it now parses both shapes. The running checklist is also reconstructed per-subagent, so parallel subagents no longer bleed their tasks into one another's cards, and a Task run whose results can't be parsed now falls back to plain tool chips instead of a misleading empty card. Existing transcripts pick this up on the next `cache-sync --rebuild`.
12
+
13
+ ## [1.37.0] - 2026-06-11
14
+
15
+ ### Added
16
+ - 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.
17
+
8
18
  ## [1.36.0] - 2026-06-11
9
19
 
10
20
  ### Added
@@ -8,6 +8,7 @@ mid-write tail line. Spec §1, §2.
8
8
  """
9
9
  from __future__ import annotations
10
10
  import json
11
+ import re
11
12
  from dataclasses import dataclass
12
13
 
13
14
  HUMAN = "human"
@@ -105,6 +106,7 @@ def _normalize(obj, t, offset):
105
106
  entry_type = TOOL_RESULT
106
107
  _attach_subagent_result(blocks, obj) # #166: record-level toolUseResult
107
108
  _attach_ask_answers(blocks, obj) # #177 S2: AskUserQuestion answers
109
+ _attach_task_meta(blocks, obj) # task checklist identity
108
110
  # tool_result rows are stored but NOT indexed as prose (spec §2). A
109
111
  # user line that mixes a text block with a tool_result block must not
110
112
  # leak that text into the FTS index; the full content stays in
@@ -287,6 +289,70 @@ def _attach_ask_answers(blocks, obj):
287
289
  results[0]["ask_annotations"] = bounded_anno
288
290
 
289
291
 
292
+ # Subagent Task tools record toolUseResult=null and put the identity in the
293
+ # human-readable result text instead; the id is the only thing the fold needs
294
+ # from the result (subject/status come from the call input). Anchored to the
295
+ # line start so unrelated output mentioning a task id mid-sentence never matches.
296
+ # Shapes verified against real subagent transcripts (Claude Code 2.1.173).
297
+ _TASK_CREATE_RESULT_RE = re.compile(r"^Task #(\d+) created\b")
298
+ _TASK_UPDATE_RESULT_RE = re.compile(r"^Updated task #(\d+)\b")
299
+
300
+
301
+ def _attach_task_meta(blocks, obj):
302
+ """Stash a Task* tool's record-level identity onto its single tool_result
303
+ block so the query-kernel fold has a robust id. Task ids are monotonic +
304
+ never reused, so the explicit id is the only stable fold key. Two result
305
+ shapes, both self-identifying off the single tool_result block:
306
+
307
+ Structured (MAIN-session Task tools) — toolUseResult carries the identity:
308
+ TaskCreate -> {"task": {"id": ...}} -> block["task_id"]
309
+ TaskUpdate -> {"taskId": ...} -> block["task_id"]
310
+ TaskList -> {"tasks": [{id,subject,status}]} -> block["task_list"]
311
+
312
+ String-content (SUBAGENT Task tools) — toolUseResult is null and the id
313
+ lives in the result text ("Task #7 created successfully: ..." / "Updated
314
+ task #3 status"); we recover the id from block["text"]. Subagent-driven
315
+ workflows make this the dominant shape, so missing it left every subagent
316
+ Task run rendering as an empty "0 / 0" card.
317
+
318
+ Same exactly-one-result-block guard as _attach_subagent_result. Subjects
319
+ bounded through _bound_input.
320
+
321
+ The ``task.id`` (not ``task.task_id``) gate deliberately ignores the
322
+ look-alike local_bash spawn result {"task": {"task_id": ..., ...}}, which is
323
+ a different tool family and carries no checklist id."""
324
+ results = [b for b in blocks if b.get("kind") == "tool_result"]
325
+ if len(results) != 1:
326
+ return
327
+ block = results[0]
328
+ tur = obj.get("toolUseResult")
329
+ if isinstance(tur, dict):
330
+ task = tur.get("task")
331
+ if isinstance(task, dict) and task.get("id") is not None:
332
+ block["task_id"] = str(task["id"])
333
+ return
334
+ if tur.get("taskId") is not None:
335
+ block["task_id"] = str(tur["taskId"])
336
+ return
337
+ tasks = tur.get("tasks")
338
+ if isinstance(tasks, list):
339
+ snap = []
340
+ for t in tasks:
341
+ if not isinstance(t, dict) or t.get("id") is None:
342
+ continue
343
+ bounded, _ = _bound_input({"subject": t.get("subject") or ""})
344
+ snap.append({"id": str(t["id"]),
345
+ "subject": bounded.get("subject", ""),
346
+ "status": t.get("status") or "pending"})
347
+ block["task_list"] = snap
348
+ return
349
+ # String-content fallback (subagent Task tools): no structured identity.
350
+ m = (_TASK_CREATE_RESULT_RE.match(block.get("text") or "")
351
+ or _TASK_UPDATE_RESULT_RE.match(block.get("text") or ""))
352
+ if m:
353
+ block["task_id"] = m.group(1)
354
+
355
+
290
356
  def _stringify(c):
291
357
  if isinstance(c, str):
292
358
  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,83 @@ 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. Key on the explicit task id (never reused). `deleted` drops a
715
+ task; a TaskList result reseeds the whole snapshot.
716
+
717
+ Scoped PER subagent thread (``subagent_key``): the main session (key None)
718
+ and each subagent keep INDEPENDENT running checklists, so parallel subagents
719
+ with disjoint task-id ranges never bleed into one another's cards. Within a
720
+ thread, state still spans the whole session.
721
+
722
+ Degradation guard: a thread's run is stamped only once that thread has
723
+ recognized a real create/list (``seen``). A Task* run with no recognizable
724
+ create — a future result shape we don't parse, or pre-fix legacy rows —
725
+ leaves ``task_snapshot`` ABSENT, so the frontend falls back to generic chips
726
+ instead of a misleading empty "0 / 0" card.
727
+
728
+ Mirrors the ask_answers join: the parser stashed the record-level identity
729
+ onto the tool_result block, the Phase-1 sweep popped it into ``task_link``
730
+ keyed by tool_use_id, and this fold joins it back. The frontend stays a pure
731
+ todos[] renderer — all running-list state lives here."""
732
+ threads = {} # subagent_key -> {"order": [...], "state": {...}, "seen": bool}
733
+
734
+ def snapshot(th):
735
+ st, order = th["state"], th["order"]
736
+ return [dict(content=st[i]["content"], status=st[i]["status"],
737
+ **({"activeForm": st[i]["activeForm"]} if st[i].get("activeForm") else {}))
738
+ for i in order if i in st]
739
+
740
+ for it in items:
741
+ if it.get("kind") != "assistant":
742
+ continue
743
+ th = threads.setdefault(it.get("subagent_key"),
744
+ {"order": [], "state": {}, "seen": False})
745
+ first_task_call = None
746
+ for b in it["blocks"]:
747
+ if b.get("kind") != "tool_call" or b.get("name") not in _TASK_TRIO:
748
+ continue
749
+ if first_task_call is None:
750
+ first_task_call = b
751
+ link = task_link.get(b.get("tool_use_id")) or {}
752
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
753
+ name = b["name"]
754
+ if name == "TaskCreate":
755
+ tid = link.get("task_id")
756
+ if tid is not None:
757
+ th["seen"] = True
758
+ if tid not in th["state"]:
759
+ th["order"].append(tid)
760
+ th["state"][tid] = {"content": inp.get("subject") or "", "status": "pending",
761
+ "activeForm": inp.get("activeForm") or ""}
762
+ elif name == "TaskUpdate":
763
+ tid = str(inp.get("taskId")) if inp.get("taskId") is not None else link.get("task_id")
764
+ status = inp.get("status")
765
+ if tid is not None:
766
+ if status == "deleted":
767
+ th["state"].pop(tid, None)
768
+ elif tid in th["state"] and status:
769
+ th["state"][tid]["status"] = status
770
+ elif name == "TaskList":
771
+ snap = link.get("task_list")
772
+ if snap is not None:
773
+ th["seen"] = True
774
+ th["order"] = []
775
+ th["state"] = {}
776
+ for t in snap:
777
+ tid = t["id"]
778
+ th["order"].append(tid)
779
+ th["state"][tid] = {"content": t.get("subject") or "",
780
+ "status": t.get("status") or "pending", "activeForm": ""}
781
+ if first_task_call is not None and th["seen"]:
782
+ first_task_call["task_snapshot"] = snapshot(th)
783
+
784
+
700
785
  def _latest(logical, col):
701
786
  """Most-recent non-null value in a column across the session (project/branch
702
787
  show the latest, matching the dashboard's session posture)."""