Repository navigation
fix(hooks): fill a session entry's tool sequence from its transcript (#591) - #592
Conversation
…591) Every session entry was written with toolsUsed: [] and turnCount: 0, because the SessionEnd payload carries neither and _build_session_entry read them from it. Measured on the live log: 413 of 413 entries empty, procedural_skills 0 rows, recall_skills always empty. infrastructure/transcript_activity.py reads the transcript the event does carry and returns the ordered tool sequence, repetitions included, plus the assistant turn count. The entry is filled from it when the event does not supply the fields; an event that carries either keeps its own value (ADR-1072). Order matters because mine_skills mines contiguous subsequences, so scanner_parse now accumulates a list rather than a set, and the scanner takes the sequence from the whole file instead of the head-and-tail sample it uses for metadata. Measured over the 374 transcripts under ~/.claude/projects: 206 carry a sequence, mine_skills returns 796 skills in 0.02 s, and a session end reads one transcript in about 4.5 ms. Proficiency stays at 0.5 for all of them: the session log records no outcome yet. Full suite: 8724 passed, 8 skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
|
ZETETIC-REVIEW: REQUEST_CHANGES SummaryThe fix is correctly targeted and its two headline claims hold up under independent re-measurement: (1) the SessionEnd payload really does leave every one of the 413 live Move 0, Ledger reconciliation and seen-defect check (§13.2 + §14)
Stakes calibration (Move 7)
Layer check (Move 1)
No boundary violations. SOLID audit (Move 2)
No violations. One structural observation (not a SOLID violation, DRY-adjacent, see Move 5) is that this PR's Wiring & contract drift (Move 3)
Test adequacy (Move 4)New execution paths and their coverage:
Mutation testing (Move 4 §3.2, task's claim 5), ran against an isolated scratch copy (the worktree was never edited):
The suite is mutation-strong for the claims it makes. It is not strong enough for the crash path below, because no test exercises it. Complexity & structure (Move 5)
Security & hygiene (Move 6)
IssuesBlocking
Non-blocking
Verified claims (independent re-measurement, same machine
|
The review of PR #592 showed transcript_activity crashing with AttributeError on a line that parses as valid JSON but is not an object, which a whole-file JSON array produces. _build_session_entry runs inside process_event with no try/except, so that would have cost the session its log entry and its profile update, worse than the empty entry it replaces. The record is now skipped like an unparseable line, and the docstring says so. Two tests cover it: a whole-file JSON array and a JSONL holding a bare string and a bare array; both raise on the previous code. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
|
ZETETIC-REVIEW: REQUEST_CHANGES Re-verification of 1b0f3dd against the prior blocking findingConfirmed: New finding: the fix does not cover the case one level deeperYou asked me to check "a dict whose content = (record.get("message") or {}).get("content")
Reproduced directly against the branch's own code: Same for Checked the remaining shapes you listed and confirmed they are already safe on this branch, no further gap found there:
Required change: guard the Gates (unchanged from the prior round, re-run on 1b0f3dd)
Standing non-blocking notesBoth stand as filed and neither should block: the VerdictREQUEST_CHANGES, same defect class as the prior round, one level deeper: guard |
The previous commit guarded the record and left the field below it
unguarded: `(record.get("message") or {}).get("content")` falls back only
when message is falsy, so a truthy non-object (a string, a list) reached
.get() and raised AttributeError in the same unguarded SessionEnd hook
path. Reproduced on the branch with {"type": "assistant", "message":
"not a dict"} before fixing.
_message_content reads the content only when message is an object, and
_tool_names already ignores a content that is not a list of blocks. Four
parametrized cases (string, list, number, bool) cover it; each raises on
the previous code, and the record still counts as a turn.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
|
ZETETIC-REVIEW: APPROVE Re-verification of 33dc284 against the round-2 blocking findingConfirmed The new Exhaustive walk of every access/subscript in transcript_activity.py (current head)Line by line, what happens when the value one level below is a string, a list, a number, a bool, or
Ran this exhaustively rather than by hand: parametrized over No remaining crash exists in the record/message/content/block shape space this round and the prior round were closing. The one raise found ( Gates on 33dc284
Standing non-blocking notesBoth still stand, unchanged from rounds 1 and 2: the VerdictAPPROVE. Three rounds closed the reported defect class completely: the record-shape crash (round 1), the message-shape crash (round 2), and an exhaustive walk of every remaining access in the file (this round) turns up nothing further within that class. The fix is correctly scoped, its tests are mutation-strong for every claim made, the numbers in the PR body and ADR-1072 were independently re-measured and held up, and every gate is green. |
#591) (#593) scanner.iter_tool_uses streamed the same assistant tool_use blocks as infrastructure/transcript_activity.py and had no caller anywhere in the repository, in any layer, script or benchmark. It also carried the two crashes the review of PR #592 closed in the new module: an unguarded .get on a record and on its message field. Deleting it puts scanner.py back under the 300-line cap, so the baseline loses that entry. transcript_activity returns its empty result for a transcript_path that is neither text nor an os.PathLike, where it raised TypeError. The value comes from the SessionEnd event's untyped JSON envelope and runs in a path with no exception handler (ADR-1073). Seven new cases cover the path types and a Path object; the type ones raise on the previous code. Full suite: 8736 passed, 8 skipped. Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Summary
Procedural learning had never produced a skill.
procedural_skillsheld 0 rows in the maintainer's store andrecall_skillsreturned an empty list, cwd-scoped and unscoped, matching the same measurement from 2026-08-25.The chain stopped at its first link:
hooks/session_lifecycle.py::_build_session_entryreadtools_usedandturn_countfrom the SessionEnd payload, which carries neither. 413 of the 413 entries in the livesession-log.jsonhavetoolsUsed: []andturnCount: 0, andprocedural_skill_writerdrops an entry with no tools, so mining always returnedskipped / insufficient_history.infrastructure/transcript_activity.pynow reads the transcript the event does carry, returning the ordered tool sequence with repetitions plus the assistant turn count, and the entry is filled from it when the event does not supply the fields (ADR-1072). Order is the evidence:core/procedural_memory.mine_skillsmines contiguous subsequences, soscanner_parseaccumulates a list rather than a set, andscanner._parse_conversation_filetakes the sequence from the whole file instead of the head-and-tail sample it uses for metadata.Closes #591
Type of change
Test plan
tests_py/infrastructure/test_transcript_activity.py: order and repetition preserved, a session with turns but no tools, a malformed line not losing the rest, a missing transcript, and theMAX_TOOL_SEQUENCEcap.TestSessionEntryActivityintests_py/hooks/test_session_lifecycle.py: the entry filled from the transcript, an event carrying its own fields winning, and no transcript leaving the entry empty.tests_py/infrastructure/test_scanner.pynow expects["Read", "Read"]where it expected["Read"]: the repetition the set used to swallow.~/.claude/projects: 206 carry a sequence (longest 971 calls, median non-empty 19), parsing all of them takes 1.7 s (about 4.5 ms each, and a session end reads one), andmine_skillsreturns 796 skills in 0.02 s. On a 120-transcript sample the strongest wereBash>ToolSearch(32 sessions),Agent>Bash(27),Bash>Agent(25).ruff check,ruff format --check: clean.pyright mcp_server/: 0 errors.scripts/check_craftsmanship.py --base origin/main: OK.scripts/check_doc_claims.py: OK.Audit notes
mine_skillstreats each session as neutral. Skills are recurrence without judgment until an outcome is recorded. That is the next step, not this one.MIN_SKILL_SUPPORT = 3.Coding-standards compliance
MAX_TOOL_SEQUENCEcarries its measurement.Breaking changes
None outward.
scanner_parse.extract_message_statsnow returnstools_usedas an ordered list rather than a set; its only consumer isbuild_conversation_record.Reviewer checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH