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
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -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)."""
|