Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions .agents/plugins/opencode-aidevops/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1502,7 +1502,10 @@ const BUILTIN_TTSR_RULES = [
{
id: "shell-local-params",
description: "Use local var=\"$1\" pattern in shell functions",
pattern: "\\$[1-9](?!.*local\\s+\\w+=.*\\$[1-9])",
// 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+=)",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
pattern: "^\\s+(?:echo|printf|return|if|\\[\\[).*\\$[1-9](?!.*local\\s+\\w+=)",
pattern: "^\\s*(?!\\s*local\\b)(?:\\w+=|echo|printf|return|if|\\[\\[).*\\$[1-9]",

correction: "Use `local var=\"$1\"` pattern — never use positional parameters directly (SonarCloud S7679).",
Comment on lines +1505 to 1509
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

severity: "warn",
systemPrompt: "Shell scripts: use `local var=\"$1\"` — never use $1 directly in function bodies.",
Expand Down Expand Up @@ -1639,16 +1642,39 @@ async function systemTransformHook(_input, output) {
* Extract text content from a Part array.
* Only extracts text from TextPart objects (type === "text").
* @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;
})
Comment on lines +1652 to +1664
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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))
    )

Comment on lines 1644 to +1664
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find the file and examine the context around the function
head -1700 .agents/plugins/opencode-aidevops/index.mjs | tail -100

Repository: 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 5

Repository: 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 2

Repository: 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 2

Repository: 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" -i

Repository: 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 2

Repository: 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 -100

Repository: 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 -80

Repository: 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 3

Repository: 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 2

Repository: 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 2

Repository: 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 -5

Repository: 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 -100

Repository: 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 1

Repository: 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/null

Repository: 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 -60

Repository: 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 -100

Repository: 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 5

Repository: 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.

.map((p) => p.text)
.join("\n");
}

/**
* Cross-turn TTSR dedup state.
* Tracks which rules have already fired and on which message IDs,
* preventing the same rule from firing repeatedly on the same content
* 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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

  1. Cross-session state leakage: The deduplication state from one conversation will incorrectly affect subsequent conversations.
  2. 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 _ttsrFiredState map, e.g., Map<sessionId, Map<ruleId, Set<messageId>>>.
  • Finding a hook that fires at the start of a session to clear this global state.


/**
* Hook: experimental.chat.messages.transform (t1304)
*
Expand All @@ -1660,6 +1686,13 @@ function extractTextFromParts(parts) {
* If violations are found, append a synthetic correction message to the
* message history so the model sees the feedback before generating.
*
* Bug fix: Three changes to prevent infinite correction loops:
* 1. Only scan assistant-authored text, excluding tool output (Read/Bash
* results contain code the assistant *read*, not code it *wrote*).
* 2. Cross-turn dedup — track which rules fired on which messages to
* prevent the same rule re-firing on the same content every turn.
* 3. Skip messages that are themselves synthetic TTSR corrections.
*
* @param {object} _input - {}
* @param {object} output - { messages: { info: Message, parts: Part[] }[] } (mutable)
*/
Expand All @@ -1669,28 +1702,52 @@ async function messagesTransformHook(_input, output) {
// Scan the last 3 assistant messages for violations
const scanWindow = 3;
const assistantMessages = output.messages
.filter((m) => m.info && m.info.role === "assistant")
.filter((m) => {
if (!m.info || m.info.role !== "assistant") return false;
// Skip synthetic TTSR correction messages that were injected previously
if (m.info.id && m.info.id.startsWith("ttsr-correction-")) return false;
return true;
})
.slice(-scanWindow);

if (assistantMessages.length === 0) return;

const allViolations = [];

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 });
}
Comment on lines +1735 to 1737
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
      }
    }
  }

}
}

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);
}
Comment on lines 1717 to +1749
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 2

Repository: marcusquinn/aidevops

Length of output: 91


🏁 Script executed:

# Look for where _ttsrFiredState is defined and initialized
rg -n "_ttsrFiredState" --type mjs -B 3 -A 3

Repository: 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 -50

Repository: 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 2

Repository: 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 -100

Repository: 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 5

Repository: 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 -30

Repository: 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 5

Repository: 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.


// Build correction context
const corrections = allViolations.map((v) => {
const severity = v.rule.severity === "error" ? "ERROR" : "WARNING";
Expand Down
2 changes: 1 addition & 1 deletion MODELS-PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Auto-generated by `generate-models-md.sh --mode performance` — do not edit man
Global model catalog is in `MODELS.md`.
Filtered by repo: `aidevops`

**Last updated**: 2026-02-24T01:54:29Z
**Last updated**: 2026-02-24T02:58:49Z

- **Pattern data points**: 200
- **Scored responses**: 18
Expand Down
2 changes: 1 addition & 1 deletion MODELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Global model catalog, routing tiers, and pricing.
Auto-generated by `generate-models-md.sh --mode global` — do not edit manually.
Per-repo performance data is in `MODELS-PERFORMANCE.md`.

**Last updated**: 2026-02-24T01:54:28Z
**Last updated**: 2026-02-24T02:58:49Z

## Model Catalog

Expand Down
Loading