cctally 1.35.0 → 1.36.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.36.0] - 2026-06-11
9
+
10
+ ### Added
11
+ - Conversation viewer: AskUserQuestion, TodoWrite, and ExitPlanMode now render as dedicated cards — the question-and-chosen-answer, a live checklist with progress, and the rendered plan with its approve/reject outcome — instead of raw JSON tool chips (#177 S2).
12
+
8
13
  ## [1.35.0] - 2026-06-11
9
14
 
10
15
  ### Added
@@ -104,6 +104,7 @@ def _normalize(obj, t, offset):
104
104
  elif any(b["kind"] == "tool_result" for b in blocks):
105
105
  entry_type = TOOL_RESULT
106
106
  _attach_subagent_result(blocks, obj) # #166: record-level toolUseResult
107
+ _attach_ask_answers(blocks, obj) # #177 S2: AskUserQuestion answers
107
108
  # tool_result rows are stored but NOT indexed as prose (spec §2). A
108
109
  # user line that mixes a text block with a tool_result block must not
109
110
  # leak that text into the FTS index; the full content stays in
@@ -249,6 +250,43 @@ def _attach_subagent_result(blocks, obj):
249
250
  block["subagent_meta"] = meta
250
251
 
251
252
 
253
+ def _attach_ask_answers(blocks, obj):
254
+ """Stash an AskUserQuestion's structured answers (+ annotations) onto its
255
+ single tool_result block (#177 S2), so the chosen option(s) have a robust
256
+ source the client can highlight without parsing the harness result string.
257
+ Self-identifying: fires only when toolUseResult carries an ``answers`` dict
258
+ (a key distinctive to AskUserQuestion), so no cross-record tool-name lookup
259
+ is needed. Same exactly-one-result-block guard as _attach_subagent_result.
260
+
261
+ answers/annotations are BOUNDED through _bound_input before storage — a
262
+ free-form "Other" answer or a long annotation note is attacker-controlled
263
+ free text, and every other free-text payload in this parser is capped
264
+ before it reaches blocks_json. Reusing _bound_input applies the same
265
+ five-axis cap (leaf/key/node/depth/total). The bound's truncation flag is
266
+ intentionally NOT surfaced (no answer-level "truncated" affordance — a
267
+ >8000-char option answer is not a realistic shape; the cap is a backstop).
268
+
269
+ An EMPTY answers dict is treated as no-capture (symmetric with the empty-
270
+ annotations drop below): emitting ``ask_answers={}`` would set a falsy
271
+ ``answers`` on the tool_call and suppress the client's result-text
272
+ fallback, so a degenerate empty payload must no-op here instead."""
273
+ tur = obj.get("toolUseResult")
274
+ if not isinstance(tur, dict):
275
+ return
276
+ answers = tur.get("answers")
277
+ if not isinstance(answers, dict) or not answers: # require a non-empty dict
278
+ return
279
+ results = [b for b in blocks if b.get("kind") == "tool_result"]
280
+ if len(results) != 1:
281
+ return
282
+ bounded_ans, _ = _bound_input(answers)
283
+ results[0]["ask_answers"] = bounded_ans
284
+ anno = tur.get("annotations")
285
+ if isinstance(anno, dict) and anno:
286
+ bounded_anno, _ = _bound_input(anno)
287
+ results[0]["ask_annotations"] = bounded_anno
288
+
289
+
252
290
  def _stringify(c):
253
291
  if isinstance(c, str):
254
292
  return c
@@ -483,6 +483,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
483
483
  # spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
484
484
  spawn_kind = {} # tool_use id -> subagent_type
485
485
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
486
+ ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
486
487
  for it in items:
487
488
  for b in it["blocks"]:
488
489
  k = b.get("kind")
@@ -495,6 +496,10 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
495
496
  meta = b.pop("subagent_meta", None)
496
497
  if aid and b.get("tool_use_id") is not None:
497
498
  agent_link[b["tool_use_id"]] = (aid, meta or {})
499
+ ans = b.pop("ask_answers", None) # #177 S2
500
+ anno = b.pop("ask_annotations", None)
501
+ if ans is not None and b.get("tool_use_id") is not None:
502
+ ask_link[b["tool_use_id"]] = (ans, anno)
498
503
  subagent_meta = {}
499
504
  for _tuid, _kind in spawn_kind.items():
500
505
  _link = agent_link.get(_tuid)
@@ -553,6 +558,11 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
553
558
  b["kind"] = "tool_call"
554
559
  b["tool_use_id"] = b.pop("id", None)
555
560
  b.setdefault("result", None)
561
+ link = ask_link.get(b["tool_use_id"]) # #177 S2
562
+ if link is not None:
563
+ b["answers"] = link[0]
564
+ if link[1]:
565
+ b["annotations"] = link[1]
556
566
 
557
567
  # ---- Phase 4: classify injected meta items (skill / command / context) ----
558
568
  # `meta` rows (the parser's isMeta classification) AND — only while the 005