fix: prevent TTSR messagesTransformHook infinite correction loop#2203
fix: prevent TTSR messagesTransformHook infinite correction loop#2203marcusquinn merged 2 commits intomainfrom
Conversation
Three fixes to the soft TTSR rule enforcement: 1. extractTextFromParts now supports excludeToolOutput option to skip tool-result parts (Read/Bash output contains code the assistant *read*, not code it *wrote* — scanning those causes false positives) 2. Cross-turn dedup via _ttsrFiredState Map — tracks which rules fired on which message IDs, preventing the same rule from re-firing on the same content every turn (which caused an infinite acknowledgment loop) 3. Tightened shell-local-params regex to only match bare $N in shell command contexts (echo/printf/return/if), not in prose or documentation 4. Skip synthetic TTSR correction messages from being scanned themselves
Summary of ChangesHello @marcusquinn, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses and resolves an infinite correction loop issue within the TTSR (soft rule enforcement) Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
WalkthroughEnhanced TTSR implementation with improved regex pattern for shell parameters, added cross-turn dedup state tracking via Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🔍 Code Quality Report�[0;35m[MONITOR]�[0m Code Review Monitoring Report �[0;34m[INFO]�[0m Latest Quality Status: �[0;34m[INFO]�[0m Recent monitoring activity: 📈 Current Quality Metrics
Generated on: Tue Feb 24 03:46:20 UTC 2026 Generated by AI DevOps Framework Code Review Monitoring |
|
There was a problem hiding this comment.
Code Review
This pull request effectively addresses an infinite loop bug in the TTSR mechanism by introducing several targeted fixes. The approach of excluding tool output from scans, adding cross-turn deduplication, and skipping synthetic messages is solid. My review focuses on further refining the implementation to improve robustness, efficiency, and prevent potential issues like memory leaks in a long-running process. I've also suggested a small tweak to the new regex to improve its accuracy.
| * across multiple LLM turns (which caused an infinite correction loop). | ||
| * @type {Map<string, Set<string>>} ruleId -> Set of messageIDs already corrected | ||
| */ | ||
| const _ttsrFiredState = new Map(); |
There was a problem hiding this comment.
The _ttsrFiredState map is declared as a module-level constant. This will cause problems in environments where the agent process is long-lived and handles multiple independent conversations or tasks, either sequentially or concurrently.
- Cross-session state leakage: The deduplication state from one conversation will incorrectly affect subsequent conversations.
- Memory leak: The map will grow indefinitely over the lifetime of the process, as it's never cleared.
This state should be scoped to a single conversation/session. Consider either:
- Passing a session-specific state object through the hook chain.
- Using a session/conversation ID to key the
_ttsrFiredStatemap, e.g.,Map<sessionId, Map<ruleId, Set<messageId>>>. - Finding a hook that fires at the start of a session to clear this global state.
| // Only match bare $N at the start of a line or after whitespace in what looks | ||
| // like a shell assignment/command context — avoids matching $1 inside prose, | ||
| // documentation, quoted examples, or tool output from file reads. | ||
| pattern: "^\\s+(?:echo|printf|return|if|\\[\\[).*\\$[1-9](?!.*local\\s+\\w+=)", |
There was a problem hiding this comment.
The updated regex is a great improvement for reducing false positives by being more context-aware. However, it seems to have become a bit too restrictive and might miss valid cases. For instance, it won't catch direct use of positional parameters in variable assignments like my_var=$1, which this rule should probably flag.
Also, the negative lookahead (?!.*local\s+\w+=) might not reliably prevent matches on the declaration line itself (e.g., local var="$1"), because the lookahead only checks characters after the $1.
Consider adjusting the pattern to include assignment contexts and use a negative lookahead at the start of the line to more robustly exclude local declarations.
| pattern: "^\\s+(?:echo|printf|return|if|\\[\\[).*\\$[1-9](?!.*local\\s+\\w+=)", | |
| pattern: "^\\s*(?!\\s*local\\b)(?:\\w+=|echo|printf|return|if|\\[\\[).*\\$[1-9]", |
| .filter((p) => { | ||
| if (!p || typeof p.text !== "string") return false; | ||
| if (p.type !== "text") return false; | ||
| // When excludeToolOutput is set, skip parts that contain tool output. | ||
| // Tool results are embedded in assistant messages as text parts whose | ||
| // content is the serialized tool response. We detect these by checking | ||
| // for the tool-invocation/tool-result type or by the presence of | ||
| // toolCallId/toolInvocationId fields that OpenCode attaches. | ||
| if (options.excludeToolOutput) { | ||
| if (p.toolCallId || p.toolInvocationId) return false; | ||
| } | ||
| return true; | ||
| }) |
There was a problem hiding this comment.
The filtering logic here is correct, but it could be made more concise and arguably more readable by combining the conditions into a single boolean expression. This avoids the nested if statements and multiple return points.
.filter(
(p) =>
p &&
p.type === "text" &&
typeof p.text === "string" &&
!(options.excludeToolOutput && (p.toolCallId || p.toolInvocationId))
)| if (!allViolations.some((av) => av.rule.id === ruleId)) { | ||
| allViolations.push({ ...v, msgId }); | ||
| } |
There was a problem hiding this comment.
Using allViolations.some(...) for deduplication inside a loop has a time complexity of O(n) for each check, making the overall violation collection O(n^2) in the worst case (where n is the number of violations).
While the number of violations is likely small in practice, using a Set to track seen rule IDs is more efficient (O(1) lookup) and can make the code's intent clearer.
You could introduce a Set before iterating through the messages to track rule IDs found within this scan.
For example:
const allViolations = [];
const seenRuleIdsInScan = new Set(); // New
for (const msg of assistantMessages) {
// ...
for (const v of violations) {
const ruleId = v.rule.id;
// ... cross-turn dedup
// Deduplicate by rule id within this scan
if (!seenRuleIdsInScan.has(ruleId)) {
allViolations.push({ ...v, msgId });
seenRuleIdsInScan.add(ruleId);
}
}
}There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
.agents/plugins/opencode-aidevops/index.mjs (1)
1669-1676: Cap_ttsrFiredStateto prevent unbounded growth.
The new Map grows with every message ID across long sessions. For A‑grade reliability, prune likeintentByCallId(Line 1392) or cap per-rule entries.♻️ Suggested cap (example)
+const TTSR_DEDUP_MAX = 5000; +const TTSR_DEDUP_PRUNE = 2500;- _ttsrFiredState.get(v.rule.id).add(v.msgId); + const firedSet = _ttsrFiredState.get(v.rule.id); + firedSet.add(v.msgId); + if (firedSet.size > TTSR_DEDUP_MAX) { + const it = firedSet.values(); + for (let i = 0; i < TTSR_DEDUP_PRUNE; i++) { + firedSet.delete(it.next().value); + } + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.agents/plugins/opencode-aidevops/index.mjs around lines 1669 - 1676, _tt srFiredState currently grows unbounded; implement the same pruning/capping approach used by intentByCallId to avoid memory bloat: when adding a message ID to _ttsrFiredState, enforce a maximum number of rule keys (evict oldest entries when the Map exceeds cap) and also cap each rule's Set size (drop oldest message IDs or use a sliding/TTL policy) so per-rule Sets and the Map cannot grow indefinitely; update functions that insert into _ttsrFiredState to perform eviction and optionally expose constants for MAX_RULE_KEYS and MAX_IDS_PER_RULE for tuning.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.agents/plugins/opencode-aidevops/index.mjs:
- Around line 1505-1509: The regex in the pattern field
("^\\s+(?:echo|printf|return|if|\\[\\[).*\\$[1-9](?!.*local\\s+\\w+=)") requires
at least one leading whitespace so it misses unindented shell lines like "echo
$1"; update the pattern to allow zero or more leading spaces (use ^\\s* instead
of ^\\s+) so it matches commands at column 0 as well, keeping the rest of the
expression and the negative lookahead (?!.*local\\s+\\w+=) intact to still
exclude cases using local var assignment.
- Around line 1717-1749: The _ttsrFiredState dedup currently keys only by
ruleId->Set of msgId which can suppress violations across sessions; change the
dedup to use a composite key built from sessionID and msgId (e.g.
`${sessionID}:${msgId}`) everywhere _ttsrFiredState is read and written (the
checks in the loop that reads firedOn via _ttsrFiredState.get(ruleId) and the
recording loop that calls _ttsrFiredState.set/get), and ensure you skip
composite dedup logic when either sessionID or msgId is empty/unavailable so no
cross-session suppression occurs.
- Around line 1644-1664: The comment above extractTextFromParts is misleading
about detection order: update it to state that parts are first filtered to type
=== "text", so excludeToolOutput only needs to skip text parts that represent
embedded tool output by checking toolCallId/toolInvocationId; remove any mention
of trying to detect tool-invocation/tool-result types (those are already
filtered out by the type check) and make the comment clearly describe that
excludeToolOutput targets text parts with OpenCode's toolCallId/toolInvocationId
markers.
---
Nitpick comments:
In @.agents/plugins/opencode-aidevops/index.mjs:
- Around line 1669-1676: _tt srFiredState currently grows unbounded; implement
the same pruning/capping approach used by intentByCallId to avoid memory bloat:
when adding a message ID to _ttsrFiredState, enforce a maximum number of rule
keys (evict oldest entries when the Map exceeds cap) and also cap each rule's
Set size (drop oldest message IDs or use a sliding/TTL policy) so per-rule Sets
and the Map cannot grow indefinitely; update functions that insert into
_ttsrFiredState to perform eviction and optionally expose constants for
MAX_RULE_KEYS and MAX_IDS_PER_RULE for tuning.
| // Only match bare $N at the start of a line or after whitespace in what looks | ||
| // like a shell assignment/command context — avoids matching $1 inside prose, | ||
| // documentation, quoted examples, or tool output from file reads. | ||
| pattern: "^\\s+(?:echo|printf|return|if|\\[\\[).*\\$[1-9](?!.*local\\s+\\w+=)", | ||
| correction: "Use `local var=\"$1\"` pattern — never use positional parameters directly (SonarCloud S7679).", |
There was a problem hiding this comment.
Regex misses unindented shell lines (Line 1508).
The comment says “start of a line,” but ^\\s+ won’t match echo $1 at column 0. That weakens enforcement and could let violations slip, hurting shell quality coverage.
🔧 Suggested fix
- pattern: "^\\s+(?:echo|printf|return|if|\\[\\[).*\\$[1-9](?!.*local\\s+\\w+=)",
+ pattern: "^\\s*(?:echo|printf|return|if|\\[\\[).*\\$[1-9](?!.*local\\s+\\w+=)",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.agents/plugins/opencode-aidevops/index.mjs around lines 1505 - 1509, The
regex in the pattern field
("^\\s+(?:echo|printf|return|if|\\[\\[).*\\$[1-9](?!.*local\\s+\\w+=)") requires
at least one leading whitespace so it misses unindented shell lines like "echo
$1"; update the pattern to allow zero or more leading spaces (use ^\\s* instead
of ^\\s+) so it matches commands at column 0 as well, keeping the rest of the
expression and the negative lookahead (?!.*local\\s+\\w+=) intact to still
exclude cases using local var assignment.
| * @param {Array<object>} parts - Array of Part objects | ||
| * @param {object} [options] - Extraction options | ||
| * @param {boolean} [options.excludeToolOutput=false] - Skip tool-result/tool-invocation parts | ||
| * @returns {string} Concatenated text content | ||
| */ | ||
| function extractTextFromParts(parts) { | ||
| function extractTextFromParts(parts, options = {}) { | ||
| if (!Array.isArray(parts)) return ""; | ||
| return parts | ||
| .filter((p) => p && p.type === "text" && typeof p.text === "string") | ||
| .filter((p) => { | ||
| if (!p || typeof p.text !== "string") return false; | ||
| if (p.type !== "text") return false; | ||
| // When excludeToolOutput is set, skip parts that contain tool output. | ||
| // Tool results are embedded in assistant messages as text parts whose | ||
| // content is the serialized tool response. We detect these by checking | ||
| // for the tool-invocation/tool-result type or by the presence of | ||
| // toolCallId/toolInvocationId fields that OpenCode attaches. | ||
| if (options.excludeToolOutput) { | ||
| if (p.toolCallId || p.toolInvocationId) return false; | ||
| } | ||
| return true; | ||
| }) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find the file and examine the context around the function
head -1700 .agents/plugins/opencode-aidevops/index.mjs | tail -100Repository: marcusquinn/aidevops
Length of output: 4340
🏁 Script executed:
# Search for Part-related type definitions, schemas, or comments
rg -n "Part\s*=|Part\s*{|interface Part|type Part|class Part" -A 5Repository: marcusquinn/aidevops
Length of output: 216
🏁 Script executed:
# Look for tool-result and tool-invocation type usage
rg -n "tool-result|tool-invocation" -B 2 -A 2Repository: marcusquinn/aidevops
Length of output: 1142
🏁 Script executed:
# Search for where toolCallId and toolInvocationId are set or checked
rg -n "toolCallId|toolInvocationId" -B 2 -A 2Repository: marcusquinn/aidevops
Length of output: 752
🏁 Script executed:
# Look for OpenCode-specific documentation or comments about Part structure
rg -n "OpenCode|tool output|tool response|Part.*schema" -iRepository: marcusquinn/aidevops
Length of output: 50377
🏁 Script executed:
# Search for where parts are created or populated in the file
rg -n "type.*tool|toolCallId|toolInvocationId" .agents/plugins/opencode-aidevops/index.mjs -B 2 -A 2Repository: marcusquinn/aidevops
Length of output: 444
🏁 Script executed:
# Look for function calls that create or process parts with tool data
rg -n "parts\[|parts\.push|\.parts\s*=" .agents/plugins/opencode-aidevops/index.mjs -B 1 -A 1 | head -100Repository: marcusquinn/aidevops
Length of output: 524
🏁 Script executed:
# Search for any type definitions or interfaces for Part
fd "\.d\.ts|\.ts$|\.mjs$" .agents/plugins/opencode-aidevops/ -exec grep -l "Part" {} \;Repository: marcusquinn/aidevops
Length of output: 262
🏁 Script executed:
# Check OpenCode documentation or configuration comments about Part structure
rg -n "TextPart|ToolResultPart|type.*:.*string" .agents/plugins/opencode-aidevops/ -A 2 | head -80Repository: marcusquinn/aidevops
Length of output: 1620
🏁 Script executed:
# Search for how messagesTransformHook processes parts and messages
rg -n "messagesTransformHook|tool-result|tool output" .agents/plugins/opencode-aidevops/index.mjs -B 3 -A 3Repository: marcusquinn/aidevops
Length of output: 3230
🏁 Script executed:
# Look for where extractTextFromParts is called to understand the context
rg -n "extractTextFromParts" .agents/plugins/opencode-aidevops/index.mjs -B 2 -A 2Repository: marcusquinn/aidevops
Length of output: 522
🏁 Script executed:
# Search for any test or validation code that shows Part structure
rg -n "toolCallId|toolInvocationId|excludeToolOutput" .agents/plugins/opencode-aidevops/ -B 2 -A 2Repository: marcusquinn/aidevops
Length of output: 2198
🏁 Script executed:
# Check if there's OpenCode API documentation in the repo
find .agents -name "*opencode*.md" -type f | xargs grep -l "Part\|part\|tool-result" 2>/dev/null | head -5Repository: marcusquinn/aidevops
Length of output: 160
🏁 Script executed:
# Search for any real usage of parts with tool information in the entire codebase
rg -n "\.parts|message.*parts|msg\.parts" .agents/plugins/opencode-aidevops/ -B 2 -A 2 | head -100Repository: marcusquinn/aidevops
Length of output: 1578
🏁 Script executed:
# Look for type field being checked elsewhere in the code
rg -n "p\.type|\.type\s*===|\.type\s*===" .agents/plugins/opencode-aidevops/index.mjs -C 1Repository: marcusquinn/aidevops
Length of output: 414
🏁 Script executed:
# Search OpenCode documentation files for Part schema
find . -name "*.md" -path "*opencode*" -exec grep -l "Part\|TextPart\|ToolResult" {} \; 2>/dev/nullRepository: marcusquinn/aidevops
Length of output: 110
🏁 Script executed:
# Check the opencode-server.md for Part schema info
cat .agents/tools/ai-assistants/opencode-server.md | grep -A 30 -i "part\|textpart\|tool.*result" | head -60Repository: marcusquinn/aidevops
Length of output: 1318
🏁 Script executed:
# Check the t1305 issue file which might have plugin details
cat ./todo/t1305-opencode-streaming-hooks-issue.md | head -100Repository: marcusquinn/aidevops
Length of output: 4282
🏁 Script executed:
# Look at the actual plugin hook parameters to understand Part structure better
rg -n "experimental\.chat\.messages\.transform|plugin.*part" .agents/plugins/opencode-aidevops/index.mjs -B 5 -A 5Repository: marcusquinn/aidevops
Length of output: 1009
Update comment to accurately reflect the code flow — type filtering happens before excludeToolOutput check.
The function filters parts by type === "text" (line 1654) before checking excludeToolOutput (line 1660). Since non-text parts like type: "tool-result" or type: "tool-invocation" are already rejected, the comment suggesting a check for those types is misleading. The excludeToolOutput logic correctly targets text parts containing tool output, which OpenCode marks via toolCallId/toolInvocationId fields. Clarify the comment to remove the reference to type-based detection, which cannot occur due to the upstream filter.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.agents/plugins/opencode-aidevops/index.mjs around lines 1644 - 1664, The
comment above extractTextFromParts is misleading about detection order: update
it to state that parts are first filtered to type === "text", so
excludeToolOutput only needs to skip text parts that represent embedded tool
output by checking toolCallId/toolInvocationId; remove any mention of trying to
detect tool-invocation/tool-result types (those are already filtered out by the
type check) and make the comment clearly describe that excludeToolOutput targets
text parts with OpenCode's toolCallId/toolInvocationId markers.
| for (const msg of assistantMessages) { | ||
| const text = extractTextFromParts(msg.parts); | ||
| const msgId = msg.info?.id || ""; | ||
|
|
||
| // Extract only assistant-authored text, excluding tool output. | ||
| // Tool results (Read, Bash, etc.) contain code the assistant *read*, | ||
| // not code it *wrote* — scanning those causes false positives. | ||
| const text = extractTextFromParts(msg.parts, { excludeToolOutput: true }); | ||
| if (!text) continue; | ||
|
|
||
| const violations = scanForViolations(text); | ||
| for (const v of violations) { | ||
| // Deduplicate by rule id | ||
| if (!allViolations.some((av) => av.rule.id === v.rule.id)) { | ||
| allViolations.push(v); | ||
| const ruleId = v.rule.id; | ||
|
|
||
| // Cross-turn dedup: skip if this rule already fired on this message | ||
| const firedOn = _ttsrFiredState.get(ruleId); | ||
| if (firedOn && firedOn.has(msgId)) continue; | ||
|
|
||
| // Deduplicate by rule id within this scan | ||
| if (!allViolations.some((av) => av.rule.id === ruleId)) { | ||
| allViolations.push({ ...v, msgId }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (allViolations.length === 0) return; | ||
|
|
||
| // Record that these rules fired on these messages (cross-turn dedup) | ||
| for (const v of allViolations) { | ||
| if (!_ttsrFiredState.has(v.rule.id)) { | ||
| _ttsrFiredState.set(v.rule.id, new Set()); | ||
| } | ||
| _ttsrFiredState.get(v.rule.id).add(v.msgId); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's search for messageID and sessionID patterns in the codebase
rg -n "messageID|sessionID|msg\.info\.id" --type mjs -B 2 -A 2Repository: marcusquinn/aidevops
Length of output: 91
🏁 Script executed:
# Look for where _ttsrFiredState is defined and initialized
rg -n "_ttsrFiredState" --type mjs -B 3 -A 3Repository: marcusquinn/aidevops
Length of output: 91
🏁 Script executed:
# Search for how info.id is set or created in messages
rg -n "info.*id|\.id\s*=" --type mjs | head -50Repository: marcusquinn/aidevops
Length of output: 91
🏁 Script executed:
# Look for session-related handling and function context
rg -n "sessionID|session\s*ID|session.*id" --type mjs -B 2 -A 2Repository: marcusquinn/aidevops
Length of output: 91
🏁 Script executed:
# Check the function containing the dedup code to see what parameters/scope it has
sed -n '1700,1760p' ".agents/plugins/opencode-aidevops/index.mjs"Repository: marcusquinn/aidevops
Length of output: 2131
🏁 Script executed:
# Search for the function definition and _ttsrFiredState initialization
rg -n "function|const.*=.*function|\(.*sessionID" ".agents/plugins/opencode-aidevops/index.mjs" -B 5 -A 2 | head -100Repository: marcusquinn/aidevops
Length of output: 3737
🏁 Script executed:
# Find _ttsrFiredState definition/initialization
rg -n "_ttsrFiredState\s*=" ".agents/plugins/opencode-aidevops/index.mjs" -B 5 -A 5Repository: marcusquinn/aidevops
Length of output: 580
🏁 Script executed:
# Check the file structure to understand context better
head -100 ".agents/plugins/opencode-aidevops/index.mjs"Repository: marcusquinn/aidevops
Length of output: 2773
🏁 Script executed:
# Look for where assistantMessages come from and the broader context
sed -n '1650,1720p' ".agents/plugins/opencode-aidevops/index.mjs"Repository: marcusquinn/aidevops
Length of output: 2963
🏁 Script executed:
# Look for how output is structured and if it contains sessionID
rg -n "output\.|session" ".agents/plugins/opencode-aidevops/index.mjs" | grep -E "(output\.|sessionID|session.*context)" | head -30Repository: marcusquinn/aidevops
Length of output: 1813
🏁 Script executed:
# Check if there's any session/conversation ID tracking in the hook context
sed -n '1680,1800p' ".agents/plugins/opencode-aidevops/index.mjs"Repository: marcusquinn/aidevops
Length of output: 4313
🏁 Script executed:
# Look for where messagesTransformHook is called and what parameters are available
rg -n "messagesTransformHook|experimental\.chat\.messages\.transform" ".agents/plugins/opencode-aidevops/index.mjs" -B 5 -A 5Repository: marcusquinn/aidevops
Length of output: 1503
Include sessionID in the dedup key to prevent cross-session suppression.
The global _ttsrFiredState Map uses only msgId, which can cause rule violations to be incorrectly suppressed across different sessions if message IDs collide or are missing. Build a composite dedup key like ${sessionID}:${msgId} and skip dedup when either component is empty or unavailable.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.agents/plugins/opencode-aidevops/index.mjs around lines 1717 - 1749, The
_ttsrFiredState dedup currently keys only by ruleId->Set of msgId which can
suppress violations across sessions; change the dedup to use a composite key
built from sessionID and msgId (e.g. `${sessionID}:${msgId}`) everywhere
_ttsrFiredState is read and written (the checks in the loop that reads firedOn
via _ttsrFiredState.get(ruleId) and the recording loop that calls
_ttsrFiredState.set/get), and ensure you skip composite dedup logic when either
sessionID or msgId is empty/unavailable so no cross-session suppression occurs.



Summary
The TTSR (soft rule enforcement)
messagesTransformHookwas causing an infinite correction loop. It scanned all text in assistant messages — including tool output from Read/Bash (code the assistant read, not code it wrote). Theshell-local-paramsrule matched$1in read shell files, injected a correction, the assistant acknowledged it, and the same rule fired again on the next turn because the read output was still in the scan window.Fixes
extractTextFromParts— newexcludeToolOutputoption skips parts withtoolCallId/toolInvocationIdfields (tool results)_ttsrFiredStateMap tracks which rules fired on which message IDs, preventing re-firing on the same content across turnsttsr-correction-) are excluded from scanningshell-local-paramsregex — only matches bare$Nin shell command contexts (echo/printf/return/if), not in prose or documentationEvidence
This bug was observed live during this session — the
shell-local-paramsrule fired ~20+ times on shell code fromcron.shandttsr-rule-loader.shthat was read via the Read tool, not written by the assistant. Each acknowledgment kept the content in context, re-triggering the rule.Summary by CodeRabbit