| 1 | import { truncate, escapeMd } from './util.js'; |
| 2 | import { SCHEMA_VERSION } from './config.js'; |
| 3 | |
| 4 | const FAILURE_TYPES = new Set([ |
| 5 | 'ignored_constraint', |
| 6 | 'misunderstood_goal', |
| 7 | 'scope_drift', |
| 8 | 'wrong_tool_choice', |
| 9 | 'hallucinated_file_or_api', |
| 10 | 'repeated_failed_fix', |
| 11 | 'overbuilt_solution', |
| 12 | 'underbuilt_solution', |
| 13 | 'security_or_privacy_risk', |
| 14 | 'dependency_or_environment_mismatch', |
| 15 | 'format_violation', |
| 16 | 'user_frustration', |
| 17 | 'abandoned_path', |
| 18 | 'user_rejected_action', |
| 19 | 'tool_execution_failed', |
| 20 | 'model_refused', |
| 21 | 'permission_denied', |
| 22 | ]); |
| 23 | |
| 24 | const REJECTION_KIND_TO_FAILURE_TYPE = { |
| 25 | user_declined_tool: 'user_rejected_action', |
| 26 | user_interrupt: 'user_rejected_action', |
| 27 | user_text_decline: 'user_rejected_action', |
| 28 | tool_execution_error: 'tool_execution_failed', |
| 29 | permission_denied: 'permission_denied', |
| 30 | model_refusal: 'model_refused', |
| 31 | }; |
| 32 | |
| 33 | function tierForRejection(confidence) { |
| 34 | if (confidence >= 0.95) return 'verified'; |
| 35 | if (confidence >= 0.8) return 'high'; |
| 36 | if (confidence >= 0.65) return 'confirmed'; |
| 37 | return 'inferred'; |
| 38 | } |
| 39 | |
| 40 | const CORRECTION_HINT = |
| 41 | /\b(no|stop|scrap|revert|undo|roll ?back|rip (?:it|that|this) out|back (?:it|that) out|not that|not it|over[- ]?engineered|you forgot|you ignored|that's wrong|that is wrong|i said|instead|redo|re do|go back|wrong|doesn'?t work|didn'?t work|still (failing|broken|wrong|bad)|not what i (asked|wanted|meant))\b/i; |
| 42 | const FRUSTRATION_HINT = |
| 43 | /\b(sucks|awful|god awful|what the heck|wtf|mad|angry|frustrat|not suffic|i don'?t trust|terrible|bad)\b/i; |
| 44 | const STRONG_FRUSTRATION_RE = |
| 45 | /\b(god awful|wtf|what the (?:heck|hell)|(?:so |really |this )?sucks|i(?:'m| am) (?:angry|frustrated|furious)|angry and frustrated|makes me (?:angry|mad|furious)|absolutely terrible|piece of (?:junk|garbage|trash|crap))\b/i; |
| 46 | const UNCORROBORATED_RECALL_TYPES = new Set(['user_frustration', 'scope_drift', 'overbuilt_solution']); |
| 47 | const PRIVACY_HINT = /\b(secret|token|api key|apikey|password|redact|privacy|private|local-first|telemetry|upload|cloud)\b/i; |
| 48 | const composeOr = (parts) => new RegExp(parts.map((p) => `(?:${p.re.source})`).join('|'), 'i'); |
| 49 | |
| 50 | export const SECURITY_INTENT_PARTS = [ |
| 51 | { name: 'credential_lifecycle', re: /\b(?:updated?|rotat(?:e|ed|ing)|regenerat(?:e|ed)|new|replaced?|revoked?)\b[^.]{0,40}\b(?:pat|personal access token|api[- ]?key|access token|secret|credential)s?\b/i }, |
| 52 | { name: 'pat_lifecycle', re: /\bpat\b[^.]{0,30}\b(?:updated?|rotat|regenerat|revoked?)/i }, |
| 53 | { name: 'email_change', re: /\b(?:make|change|set|update|use)\b[^.]{0,30}\bemail\b(?=[^.]*@|[^.]*\bcontact\b|[^.]*\bpublic\b)/i }, |
| 54 | { name: 'do_not_expose', re: /\b(?:don'?t|do not|never)\b[^.]{0,20}\b(?:expose|leak)\b/i }, |
| 55 | { name: 'expose_us', re: /\bexpose us\b/i }, |
| 56 | { name: 'leak_list', re: /\bleak (?:anything|audit|nothing|secrets?|creds?)\b/i }, |
| 57 | { name: 'audit_repos', re: /\b(?:full )?audit\b[^.]{0,40}\b(?:repo|repos|repositor|organization|git commit|commit history)\b/i }, |
| 58 | { name: 'commit_history_audit', re: /\bcommit history\b[^.]{0,30}\b(?:audit|expose|leak|clean)\b/i }, |
| 59 | { name: 'relicensing', re: /\b(?:re-?licens(?:e|ing)|licens(?:e|ing) (?:adjustment|change)|chang(?:e|ing)[^.]{0,15}licens)\b/i }, |
| 60 | { name: 'disable_tests', re: /\b(?:disabl|skip|remov|delet)\w*\b[^.]{0,15}\btests?\b/i }, |
| 61 | { name: 'access_control_change', re: /\b(?:change|modify|update|add|tighten|loosen|fix)\b[^.]{0,20}\b(?:access control|permissions?|rbac|auth flow)\b/i }, |
| 62 | ]; |
| 63 | const SECURITY_INTENT_RE = composeOr(SECURITY_INTENT_PARTS); |
| 64 | const SCOPE_DRIFT_HINT = /\b(don'?t add|do not add|not a web app|keep it local|too much|overbuilt|over[- ]?engineered|over[- ]?kill|scope drift|stay focused|same format|keep .* cli|zero-config cli|way more than|more than i (?:wanted|asked|need)|not a (?:platform|framework|service|product|web ?app|library|server)|a (?:script|function|cli|tool|one[- ]?liner) not|rip (?:it |that |the )?out|too (?:complex|complicated|heavy|big)|simpler than this)\b/i; |
| 65 | const SURPLUS_CUE_RE = |
| 66 | /\bgold[- ]?plat(?:e|ed|ing)?\b|\bover[- ]?build|\bover[- ]?engineer|\bcannon for a (?:fly|mosquito)\b|\bwrench(?:,)? not a (?:workshop|factory)\b|\btrim (?:it|this) (?:way )?down\b|\bway too (?:much|heavy|big|complex)\b|\bmore than (?:i|we) (?:asked|wanted|needed)\b/i; |
| 67 | const REMOVE_COMPONENTS_RE = |
| 68 | /\b(?:rip|ditch|drop|strip|tear|gut|remove|delete|cut)\b[^.]{0,60}\b(registry|middleware|daemon|plugin|panel|layer|engine|scheduler|system|framework|theme)\b/i; |
| 69 | const TOOL_HINT = /\b(wrong tool|wrong library|use .* instead|don'?t use|dependency|package|environment|node version|python version|missing module)\b/i; |
| 70 | const HALLUCINATION_HINT = /\b(hallucinat|doesn'?t exist|does not exist|no such file|fake file|fake api|made up)\b/i; |
| 71 | const REPEATED_FIX_HINT = /\b(still failing|still broken|still wrong|again|same error|didn'?t fix|doesn'?t fix|keeps? failing|redo)\b/i; |
| 72 | const UNDERBUILT_HINT = /\b(underbuilt|missing|not enough|too bare|incomplete|you skipped|you missed)\b/i; |
| 73 | const FORMAT_HINT = |
| 74 | /\b(?:format|reformat|malformed|same structure|exact output)\b|\binvalid (?:json|yaml|xml|format|output|structure|markup|schema)\b/i; |
| 75 | const MISUNDERSTOOD_GOAL_RE = |
| 76 | /\b(?:that'?s not what i (?:asked|wanted|meant)|not what i (?:asked|wanted|meant)|you (?:misunderstood|got it wrong|missed the point|misread|solved the wrong|optimi[sz]ed the wrong|chose the wrong)|i (?:wanted|meant|asked for|cared about)\b[^.]*\bnot\b|wrong (?:goal|thing|feature|approach|task|problem|axis|optimization|metric)|you built the wrong|that'?s the wrong)\b/i; |
| 77 | const REVERSAL_VERB_RE = /\b(?:rip|nix|scrap|yank|gut|tear|strip|pull)\b[^.]{0,60}\bout\b|\b(?:nix|scrap|yank|gut)\b/i; |
| 78 | |
| 79 | const WORDING_SCAN_MAX_CHARS = 1200; |
| 80 | const SIGNAL_PRIORITY = [ |
| 81 | 'ignored_constraint', |
| 82 | 'hallucinated_file_or_api', |
| 83 | 'wrong_tool_choice', |
| 84 | 'repeated_failed_fix', |
| 85 | 'scope_drift', |
| 86 | 'overbuilt_solution', |
| 87 | 'underbuilt_solution', |
| 88 | 'dependency_or_environment_mismatch', |
| 89 | 'format_violation', |
| 90 | 'user_frustration', |
| 91 | 'misunderstood_goal', |
| 92 | ]; |
| 93 | const STOPWORDS = new Set([ |
| 94 | 'the', 'and', 'for', 'this', 'that', 'with', 'you', 'your', 'are', 'was', 'has', 'have', |
| 95 | 'not', 'but', 'can', 'all', 'any', 'our', 'out', 'now', 'too', 'also', 'please', 'lol', |
| 96 | 'from', 'into', 'just', 'like', 'more', 'some', 'than', 'then', 'them', 'they', 'what', |
| 97 | 'when', 'where', 'which', 'will', 'about', 'agent', 'make', 'made', 'show', 'look', |
| 98 | ]); |
| 99 | |
| 100 | const PROCESS_LABEL_CAP = 2; |
| 101 | const CONSTRAINT_PER_NODE_CAP = 3; |
| 102 | const CONSTRAINT_LIST_CAP = 10; |
| 103 | const CONSTRAINT_CLAUSE_MAX = 160; |
| 104 | const CONSTRAINT_DIRECTIVE_RE = |
| 105 | /\b(?:no|don'?t|do not|never|must(?: not)?|always|only|make sure|ensure|avoid|keep it|keep the|stay|don'?t add|do not add|no longer|stop|without|not a|never use|never add)\b/i; |
| 106 | const CONSTRAINT_DESCRIPTIVE_RE = |
| 107 | /\b(?:i (?:don'?t|do not|can'?t|cannot)\b[^.]*\b(?:see|know|understand|think|see)|do you|does this|is this|why (?:do|does|is|are)|what (?:url|do|is|are)|how (?:do|does|can)|can you|could you|would (?:fable|it)|i (?:like|agree|see|don'?t see)\b)/i; |
| 108 | const CONSTRAINT_NAMED = [ |
| 109 | { re: /\b(?:no|don'?t add|do not add|without|never add)\b[^.]{0,20}\b(?:in[\s-]?line)\s+(?:code\s+)?comments?\b/i, label: 'No inline code comments in shipped code' }, |
| 110 | { re: /\b(?:no|without|avoid)\b[^.]{0,30}\bem[\s-]?dash/i, label: 'No em dashes' }, |
| 111 | { re: /\bem[\s-]?dash(?:es)?\b[^.]{0,30}\b(?:no|avoid|never|remove|don'?t)\b/i, label: 'No em dashes' }, |
| 112 | { re: /\b(?:keep|stays?|still says?|must be|use)\b[^.]{0,20}\bapache\b/i, label: 'License must stay Apache' }, |
| 113 | { re: /\bapache\b[^.]{0,20}\b(?:licens|2\.0)\b/i, label: 'License must stay Apache' }, |
| 114 | { re: /\b(?:zero|no)[\s-]?(?:new\s+)?dependenc(?:y|ies)\b/i, label: 'Zero dependencies' }, |
| 115 | { re: /\b(?:local[\s-]?(?:first|only)|no\s+(?:network|telemetry|uploads?|cloud))\b/i, label: 'Local-only, no network or telemetry' }, |
| 116 | { re: /\b(?:don'?t|do not|never)\b[^.]{0,30}\b(?:expose|leak)\b/i, label: 'Do not expose or leak secrets' }, |
| 117 | { re: /\bnarrow(?:ing)?\b[^.]{0,30}\bnot\b[^.]{0,20}\b(?:adding|features?)\b/i, label: 'Narrow the product, do not add features' }, |
| 118 | { re: /\b(?:no\s+ai|ai[\s-]?(?:generated|authored|written|tell))\b/i, label: 'No AI-authorship tells' }, |
| 119 | ]; |
| 120 | |
| 121 | const DESTRUCTIVE_RE = |
| 122 | /\b(?:messed up|screwed up|broke|broken|deleted|wiped|nuked|lost|gone|overwrote|overwritten|corrupted|trashed|removed by accident|accidentally (?:deleted|removed|overwrote|ran))\b/i; |
| 123 | const RECOVERY_RE = |
| 124 | /\b(?:bring it back|bring them back|restore|recover|undo|revert|roll(?: |-)?back|get it back|put it back|can you (?:fix|recover|restore)|recreate)\b/i; |
| 125 | const APOLOGY_RE = /\b(?:i'?m sorry|im sorry|sorry|my bad|my fault|oops|whoops)\b/i; |
| 126 | const REMEDIATION_RE = new RegExp(`${DESTRUCTIVE_RE.source}|${RECOVERY_RE.source}`, 'i'); |
| 127 | const FIGURATIVE_DESTRUCTIVE_RE = /\bbroke my (?:brain|heart|mind|spirit)\b|\bbroken (?:heart|record)\b|\bmind[- ]?blow/i; |
| 128 | const NOT_AGENT_DISCLAIMER_RE = |
| 129 | /\bnot your (?:change|fault|code|edit|doing|problem)\b|\bpre-?existing\b|\bunrelated to your\b|\bnot (?:from|caused by|due to) your\b|\balready (?:broken|failing|broke) before\b|\bignore it\b/i; |
| 130 | |
| 131 | const SECURITY_FILE_RE = /(?:^|[\\/])(?:\.env[^\\/]*|[^\\/]*(?:auth|session|middleware|login|signin|signup|permission|rbac|access[-_]?control|secur|crypto|jwt|oauth|passwd|password|secret|credential|token)[^\\/]*)$/i; |
| 132 | const SECURITY_FILE_EXCLUDE_RE = /(?:^|[\\/])(?:[^\\/]*tokens?\.[a-z]+|tokenizer[^\\/]*|[^\\/]*[-_.]?token(?:izer|s)?\.(?:tsx?|jsx?|css|scss|json|svg)|semantic[-_]?tokens?[^\\/]*|design[-_]?tokens?[^\\/]*)$/i; |
| 133 | export const RISKY_CMD_PARTS = [ |
| 134 | { name: 'rm_rf_combined', re: /\brm\s+(?:-[a-zA-Z]*\s+)*-[a-zA-Z]*(?:rf|fr)[a-zA-Z]*\b/i }, |
| 135 | { name: 'rm_r_then_f', re: /\brm\s+(?:-[a-zA-Z]*\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:-[a-zA-Z]*\s+)*-[a-zA-Z]*f[a-zA-Z]*\b/i }, |
| 136 | { name: 'rm_f_then_r', re: /\brm\s+(?:-[a-zA-Z]*\s+)*-[a-zA-Z]*f[a-zA-Z]*\s+(?:-[a-zA-Z]*\s+)*-[a-zA-Z]*r[a-zA-Z]*\b/i }, |
| 137 | { name: 'chmod_world_writable', re: /\bchmod\s+(?:-[a-zA-Z]+\s+)*0?777\b/i }, |
| 138 | { name: 'curl_pipe_shell', re: /(?:curl|wget)[^|\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh)\b/i }, |
| 139 | { name: 'shell_process_substitution', re: /\b(?:sh|bash|zsh|dash|ksh)\s+<\(\s*(?:curl|wget)\b/i }, |
| 140 | { name: 'no_verify', re: /--no-verify\b/i }, |
| 141 | { name: 'force', re: /--force(?![\w-])/i }, |
| 142 | { name: 'drop_table', re: /\bDROP\s+TABLE\b/i }, |
| 143 | { name: 'drop_schema', re: /\bDROP\s+SCHEMA\b/i }, |
| 144 | { name: 'truncate', re: /\bTRUNCATE\s+(?:TABLE\s+)?[\w."`]+/i }, |
| 145 | ]; |
| 146 | const RISKY_CMD_RE = composeOr(RISKY_CMD_PARTS); |
| 147 | const SECRET_CONTENT_RE = /(?:\bsource\s+[^\n]*\.env\b|(?:^|[;&|]|\s)\.\s+[^\n]*\.env\b|\.env\.(?:secrets|local|prod|production)\b|\bexport\s+[A-Z0-9_]*(?:_API_KEY|_TOKEN|_SECRET|_PASSWORD|API_KEY|SECRET_KEY|ACCESS_KEY|PRIVATE_KEY)\b|\b(?:wrangler|doppler|vault)\b|\bgh\s+auth\b|\baws\s+configure\b|\bgcloud\s+auth\b|\bkubectl\s+config\s+set-credentials\b|\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[A-Z0-9]{12,}\b|\b(?:gh[opusr]|github_pat)[-_][A-Za-z0-9_]{16,}\b|\bsk-[A-Za-z0-9]{16,}\b|\bxox[baprs]-[A-Za-z0-9-]{10,}\b|\b(?:aws_secret_access_key|aws_access_key_id|api[_-]?key|secret[_-]?key|access[_-]?key|secret[_-]?access[_-]?key|private[_-]?key|client[_-]?secret|password|passwd|auth[_-]?token|access[_-]?token|bearer[_-]?token|connection[_-]?string)\b\s*[:=]\s*['"][^'"\n]{6,}['"])/i; |
| 148 | const ACCESS_CONTROL_CONTENT_RE = /\b(?:grant\s+(?:select|insert|update|delete|all)\b|setfacl|chmod\s+[0-7]{3,4}\b|public[- ]?read(?:-write)?\b|world[- ]?readable\b|--acl[= ]public|0\.0\.0\.0\/0|publicly[- ]?(?:readable|accessible|writable)\b|"?principal"?\s*:\s*"?\*)/i; |
| 149 | const ACCESS_CONTROL_WEAK_RE = /\b(?:rbac|access[-_]?control)\b/i; |
| 150 | |
| 151 | const VENDOR_TOKEN_RE = |
| 152 | /\bsk_live_[A-Za-z0-9]{16,}\b|\bsk-[A-Za-z0-9]{16,}\b|\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[A-Z0-9]{12,}\b|\b(?:gh[opusr]|github_pat)[-_][A-Za-z0-9_]{16,}\b|\bxox[baprs]-[A-Za-z0-9-]{10,}\b|\bAIza[A-Za-z0-9_-]{20,}\b|-----BEGIN(?:\s+[A-Z]+)?\s+PRIVATE KEY-----|"type"\s*:\s*"service_account"[\s\S]{0,400}?"private_key"\s*:/; |
| 153 | const SECRET_KV_RE = |
| 154 | /(?:^|[^A-Za-z0-9])(?:[A-Za-z0-9-]+[_-])?(?:password|passwd|secret(?:[_-]?key)?|api[_-]?key|access[_-]?key|secret[_-]?access[_-]?key|auth[_-]?token|access[_-]?token|bearer[_-]?token|private[_-]?key|client[_-]?secret|token)\s*[:=]\s*(['"]?)([^'"\n\r]{8,})\1/i; |
| 155 | const SECRET_PLACEHOLDER_RE = |
| 156 | /^(?:<[^>]*>|\{\{?[^}]*\}?\}|\$\{?[A-Za-z0-9_]+\}?|changeme|change_me|your[_-]?\w*|example|placeholder|redacted|todo|none|null|true|false|xxx+|\*{3,}|\.{3,}|secret|password|token|key|test|dummy|sample|foobar)$/i; |
| 157 | |
| 158 | const REDACTED_SECRET_RE = |
| 159 | /\[REDACTED:(?:private-key-block|aws-access-key|github-token|github-fine-grained|gitlab-token|anthropic-key|openai-key|slack-token|stripe-live-key|npm-token|tailscale-key|google-api-key|sendgrid-key|twilio-key|telegram-bot-token|discord-webhook|jwt|hex-token|wireguard-key|url-basic-auth|bearer-header|secret-assignment)\]/; |
| 160 | |
| 161 | function shannonEntropy(str) { |
| 162 | if (!str) return 0; |
| 163 | const freq = Object.create(null); |
| 164 | for (const ch of str) freq[ch] = (freq[ch] || 0) + 1; |
| 165 | let h = 0; |
| 166 | const n = str.length; |
| 167 | for (const k in freq) { |
| 168 | const p = freq[k] / n; |
| 169 | h -= p * Math.log2(p); |
| 170 | } |
| 171 | return h; |
| 172 | } |
| 173 | |
| 174 | function isSecretByValue(body) { |
| 175 | if (typeof body !== 'string' || !body) return false; |
| 176 | if (REDACTED_SECRET_RE.test(body)) return true; |
| 177 | if (VENDOR_TOKEN_RE.test(body)) return true; |
| 178 | const m = SECRET_KV_RE.exec(body); |
| 179 | if (m) { |
| 180 | const value = m[2].trim(); |
| 181 | if ( |
| 182 | value.length >= 8 && |
| 183 | !SECRET_PLACEHOLDER_RE.test(value) && |
| 184 | shannonEntropy(value) >= 2.5 && |
| 185 | /[A-Za-z]/.test(value) && |
| 186 | /[0-9!@#$%^&*\-_]/.test(value) |
| 187 | ) { |
| 188 | return true; |
| 189 | } |
| 190 | } |
| 191 | return false; |
| 192 | } |
| 193 | |
| 194 | const CONFIG_SURFACE_PATH_RE = |
| 195 | /(?:^|[\\/])(?:[^\\/]*\.(?:tfvars?|env[^\\/]*)|[^\\/]*\.env|[^\\/]*configmap[^\\/]*\.ya?ml|docker-compose[^\\/]*\.ya?ml|compose[^\\/]*\.ya?ml|[^\\/]*values\.ya?ml|values-[^\\/]*\.ya?ml|[^\\/]*\.tf)$/i; |
| 196 | const CONFIG_KV_RE = |
| 197 | /(?:^|[^A-Za-z0-9_])([A-Za-z_][A-Za-z0-9_-]*)\s*[:=]\s*(['"]?)([^'"\n\r]{12,})\2/; |
| 198 | const HEX_DIGEST_RE = /^[0-9a-fA-F]{32,}$/; |
| 199 | const B64_BLOB_RE = /^[A-Za-z0-9+/]{44,}={0,2}$/; |
| 200 | function isConfigSurfaceSecret(body, file) { |
| 201 | if (typeof body !== 'string' || !body || !file) return false; |
| 202 | const surface = classifySecuritySurface(file); |
| 203 | if (!(surface === 'secrets' || surface === 'deployment' || surface === 'ci')) return false; |
| 204 | if (!CONFIG_SURFACE_PATH_RE.test(file)) return false; |
| 205 | const m = CONFIG_KV_RE.exec(body); |
| 206 | if (!m) return false; |
| 207 | const value = m[3].trim(); |
| 208 | if (value.length < 12) return false; |
| 209 | if (SECRET_PLACEHOLDER_RE.test(value)) return false; |
| 210 | if (HEX_DIGEST_RE.test(value)) return false; |
| 211 | if (B64_BLOB_RE.test(value)) return false; |
| 212 | if (shannonEntropy(value) < 3.0) return false; |
| 213 | const hasSeparator = /[-_.:/+!@#$%^&*]/.test(value); |
| 214 | const hasCaseMix = /[a-z]/.test(value) && /[A-Z]/.test(value); |
| 215 | if (!hasSeparator && !hasCaseMix) return false; |
| 216 | return true; |
| 217 | } |
| 218 | |
| 219 | const PUBLIC_CIDR_RE = /\b0\.0\.0\.0\/0\b|::\/0/; |
| 220 | const PUBLIC_ACL_PAIR_RE = |
| 221 | /\b(?:acl|visibility|public|access|principal|allow|ingress)\b\s*[:=]\s*['"]?\s*(?:\*|(?:public|anyone|everyone|allusers|allauthenticatedusers|0\.0\.0\.0)\b)/i; |
| 222 | const GRANT_TO_PUBLIC_RE = /\bgrant\b[^;]{0,120}?\bto\s+(?:public\b|\*)/i; |
| 223 | |
| 224 | function chmodWorldExposed(body) { |
| 225 | const re = /\bchmod\s+(?:-[a-zA-Z]+\s+)*0?([0-7])([0-7])([0-7])\b/gi; |
| 226 | let m; |
| 227 | while ((m = re.exec(body)) !== null) { |
| 228 | if (Number(m[3]) >= 4) return true; |
| 229 | } |
| 230 | return false; |
| 231 | } |
| 232 | |
| 233 | function isPublicExposure(body) { |
| 234 | if (typeof body !== 'string' || !body) return false; |
| 235 | if (PUBLIC_CIDR_RE.test(body)) return true; |
| 236 | if (PUBLIC_ACL_PAIR_RE.test(body)) return true; |
| 237 | if (chmodWorldExposed(body)) return true; |
| 238 | if (GRANT_TO_PUBLIC_RE.test(body)) return true; |
| 239 | return false; |
| 240 | } |
| 241 | |
| 242 | const SAFETY_FLAG_OFF_RE = |
| 243 | /\b(secure|http[-_]?only|verify|verify[-_]?ssl|ssl[-_]?verify|reject[-_]?unauthorized|strict|strict[-_]?ssl|csrf|csrf[-_]?protection|check[-_]?hostname|validate[-_]?certs?|tls[-_]?verify|cert[-_]?verify|require[-_]?auth|auth[-_]?required|enforce[-_]?https|signature[-_]?verification)\b\s*[:=]\s*(?:false|0|off|no|none|disabled)\b/i; |
| 244 | const GUARD_COMMENTED_OUT_RE = |
| 245 | /(?:\/\/|#|--|<!--)\s*(?:require[-_]?auth|auth[-_]?required|check[-_]?permission|check[-_]?auth|verify[-_]?token|csrf[-_]?protect\w*|authorize|authenticate|ensure[-_]?(?:auth|admin|login)|is[-_]?authenticated|login[-_]?required|permission[-_]?required|guard|enforce[-_]?https|validate[-_]?(?:token|session|cert))\b/i; |
| 246 | |
| 247 | function isSafetyGateWeakening(body) { |
| 248 | if (typeof body !== 'string' || !body) return false; |
| 249 | if (SAFETY_FLAG_OFF_RE.test(body)) return true; |
| 250 | if (GUARD_COMMENTED_OUT_RE.test(body)) return true; |
| 251 | return false; |
| 252 | } |
| 253 | |
| 254 | const CREDENTIAL_NOUN_RE = |
| 255 | /\b(?:password|passwd|bearer(?:\s+token)?|api[\s-]?key|access[\s-]?token|signing[\s-]?token|signing[\s-]?key|secret(?:\s+key)?|secrets?|credential|credentials|service[\s-]?account(?:\s+json)?|sa[\s-]?key|authorization(?:\s+header)?|auth[\s-]?token|private[\s-]?key|connection[\s-]?string|client[\s-]?secret|access[\s-]?key|token)\b/i; |
| 256 | const CREDENTIAL_SINK_VERB_RE = |
| 257 | /\b(?:log(?:s|ged|ging)?|print(?:s|ed|ing)?|echo(?:ed|ing)?|dump(?:s|ed|ing)?|console\.log|fmt\.Print\w*|System\.out|commit(?:s|ted|ting)?|push(?:es|ed|ing)?|expose(?:s|d)?|exposing|output(?:s|ted|ting)?|writ(?:e|es|ing|ten)\s+(?:to|into)\s+(?:the\s+)?log)\b/i; |
| 258 | const CREDENTIAL_REMEDIATION_RE = |
| 259 | /\b(?:remov(?:e|es|ed|ing)|redact(?:s|ed|ing)?|mask(?:s|ed|ing)?|scrub(?:s|bed|bing)?|rotat(?:e|es|ed|ing)|revok(?:e|es|ed|ing)|strip(?:s|ped|ping)?|sanitiz(?:e|es|ed|ing)|fingerprint|last[\s-]?four|last-?4)\b/i; |
| 260 | |
| 261 | function clauseSplit(body) { |
| 262 | return String(body || '').split(/[.!?;\n]+/); |
| 263 | } |
| 264 | |
| 265 | function credentialMishandlingClause(body) { |
| 266 | if (typeof body !== 'string' || !body) return null; |
| 267 | for (const clause of clauseSplit(body)) { |
| 268 | if (!CREDENTIAL_NOUN_RE.test(clause)) continue; |
| 269 | if (!CREDENTIAL_SINK_VERB_RE.test(clause)) continue; |
| 270 | if (CREDENTIAL_REMEDIATION_RE.test(clause)) continue; |
| 271 | return clause.replace(/\s+/g, ' ').trim(); |
| 272 | } |
| 273 | return null; |
| 274 | } |
| 275 | |
| 276 | function isCredentialFile(file) { |
| 277 | if (!file || !SECURITY_FILE_RE.test(file)) return false; |
| 278 | if (SECURITY_FILE_EXCLUDE_RE.test(file)) return false; |
| 279 | return true; |
| 280 | } |
| 281 | |
| 282 | function securityConcernKey(secActs) { |
| 283 | if (!Array.isArray(secActs) || !secActs.length) return null; |
| 284 | const strong = secActs.filter((s) => s.strong); |
| 285 | const pick = (list) => { |
| 286 | for (const s of list) { |
| 287 | const f = s.action && s.action.file; |
| 288 | if (f && (isCredentialFile(f) || classifySecuritySurface(f))) return f; |
| 289 | } |
| 290 | return null; |
| 291 | }; |
| 292 | const file = pick(strong) || pick(secActs); |
| 293 | if (!file) return null; |
| 294 | return String(file).toLowerCase().replace(/\\/g, '/').replace(/\/+/g, '/'); |
| 295 | } |
| 296 | |
| 297 | const CRED_STEM_RULES = [ |
| 298 | { stem: 'private-key', re: /\bprivate[\s_-]?key\b/i }, |
| 299 | { stem: 'signing-secret', re: /\bsigning[\s_-]?(?:secret|key)\b/i }, |
| 300 | { stem: 'jwt', re: /\bjwt\b/i }, |
| 301 | { stem: 'api-key', re: /\bapi[\s_-]?key\b/i }, |
| 302 | { stem: 'bearer', re: /\bbearer\b/i }, |
| 303 | { stem: 'password', re: /\b(?:password|passwd)\b/i }, |
| 304 | ]; |
| 305 | function credentialStemKey(secActs) { |
| 306 | if (!Array.isArray(secActs) || !secActs.length) return null; |
| 307 | let text = ''; |
| 308 | for (const s of secActs) { |
| 309 | text += ` ${s.evidence || ''}`; |
| 310 | if (s.action) text += ` ${s.action.command || ''} ${s.action.input || ''}`; |
| 311 | } |
| 312 | if (!text.trim()) return null; |
| 313 | for (const rule of CRED_STEM_RULES) { |
| 314 | if (rule.re.test(text)) return rule.stem; |
| 315 | } |
| 316 | return null; |
| 317 | } |
| 318 | |
| 319 | const NARRATED_SECURITY_INTENT_RE = |
| 320 | /\b(?:re-?licens(?:e|ed|ing)|rewrote|rewrite|all[\s-]?rights[\s-]?reserved|proprietary[\s-]?licens\w*|strip(?:s|ped|ping)?|disabl(?:e|ed|ing)|remov(?:e|ed|ing)|delet(?:e|ed|ing)|leak(?:s|ed|ing)?|expos(?:e|ed|ing)|bypass(?:es|ed|ing)?)\b/i; |
| 321 | const NARRATED_SECURITY_TARGET_RE = |
| 322 | /\b(?:licens\w*|authentication|authorization|auth(?:[\s-]?(?:check|flow|token|guard))|secret\w*|credential\w*|access[\s-]?control|permissions?|rbac|admin (?:schema|mutations?|routes?)|(?:unit|integration|e2e|smoke|auth)?\s*tests?\b)\b/i; |
| 323 | function narratedSecurityIntentClause(body) { |
| 324 | if (typeof body !== 'string' || !body) return null; |
| 325 | for (const clause of clauseSplit(body)) { |
| 326 | if (!NARRATED_SECURITY_INTENT_RE.test(clause)) continue; |
| 327 | if (!NARRATED_SECURITY_TARGET_RE.test(clause)) continue; |
| 328 | if (CREDENTIAL_REMEDIATION_RE.test(clause)) continue; |
| 329 | return clause.replace(/\s+/g, ' ').trim(); |
| 330 | } |
| 331 | return null; |
| 332 | } |
| 333 | const SECURITY_CORRECTION_KINDS = new Set(['user_text_decline', 'user_declined_tool', 'user_interrupt']); |
| 334 | function narratedSecurityIntent(node) { |
| 335 | if (!node) return null; |
| 336 | for (const a of node.actions || []) { |
| 337 | const clause = narratedSecurityIntentClause(String(a.narration || '')); |
| 338 | if (clause) return clause; |
| 339 | } |
| 340 | const isUserComplaint = |
| 341 | (Array.isArray(node.rejections) && |
| 342 | node.rejections.some((r) => SECURITY_CORRECTION_KINDS.has(r.kind))) || |
| 343 | hasSecurityCorrection(node.text); |
| 344 | if (!isUserComplaint && typeof node.text === 'string' && node.text.length <= 1200) { |
| 345 | const clause = narratedSecurityIntentClause(node.text); |
| 346 | if (clause) return clause; |
| 347 | } |
| 348 | return null; |
| 349 | } |
| 350 | |
| 351 | const SECURITY_SURFACE_RULES = [ |
| 352 | { surface: 'auth', re: /(?:^|[\\/])[^\\/]*(?:auth|login|signin|signup|session|oauth|jwt|sso|saml)[^\\/]*$/i }, |
| 353 | { surface: 'secrets', re: /(?:^|[\\/])(?:\.env[^\\/]*|[^\\/]*(?:secret|credential|password|passwd|apikey|api[-_]key|token)[^\\/]*)$/i }, |
| 354 | { surface: 'access-control', re: /(?:^|[\\/])[^\\/]*(?:rbac|permission|access[-_]?control|policy|policies|guard|middleware)[^\\/]*$/i }, |
| 355 | { surface: 'crypto', re: /(?:^|[\\/])[^\\/]*(?:crypto|cipher|encrypt|decrypt|hash|hmac|signature|signing)[^\\/]*$/i }, |
| 356 | { surface: 'dependency-config', re: /(?:^|[\\/])(?:package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|requirements\.txt|pyproject\.toml|Pipfile|go\.mod|Cargo\.toml|Gemfile)$/i }, |
| 357 | { surface: 'ci', re: /(?:^|[\\/])(?:\.github[\\/]workflows[\\/][^\\/]+|\.gitlab-ci\.yml|\.circleci[\\/][^\\/]+|azure-pipelines\.yml|Jenkinsfile)$/i }, |
| 358 | { surface: 'deployment', re: /(?:^|[\\/])(?:Dockerfile|docker-compose[^\\/]*\.ya?ml|[^\\/]*\.(?:tf|tfvars)|wrangler\.toml|vercel\.json|netlify\.toml|fly\.toml|[^\\/]*deploy[^\\/]*)$/i }, |
| 359 | { surface: 'tests', re: /(?:^|[\\/])[^\\/]*(?:\.(?:test|spec)\.[a-z0-9]+|_test\.[a-z0-9]+|test_[^\\/]+)$|(?:^|[\\/])(?:tests?|__tests__|spec)[\\/]/i }, |
| 360 | ]; |
| 361 | const TEST_SKIP_API_RE = |
| 362 | /\b(?:test|it|describe|context|suite|t)\.(?:skip|only|todo)\b|\bx(?:it|describe|test|context)\s*\(|\bf(?:it|describe)\s*\(|@(?:Disabled|Ignore|Skip)\b|\bpytest\.mark\.skip\w*|\b(?:skip|disabl\w*|remov\w*|delet\w*|drop)\b[^.\n]{0,24}\b(?:e2e|integration|unit|smoke|auth)?\s*(?:tests?|specs?|suite)\b|\b(?:tests?|specs?|suite)\b[^.\n]{0,24}\b(?:disabl|skip|remov|delet|comment(?:ed)? out|turn(?:ed)? off)\w*|--no-tests?\b|--skip-tests?\b/i; |
| 363 | const TEST_SKIP_RE = |
| 364 | /\b(?:disabl|skip|remov|delet|comment(?:ed)? out|drop|turn(?:ed)? off|x?(?:it|describe)\.skip|--no-tests?|--skip-tests?)\w*\b[^.\n]{0,24}\btests?\b|\btests?\b[^.\n]{0,24}\b(?:disabl|skip|remov|delet|comment(?:ed)? out|turn(?:ed)? off)\w*/i; |
| 365 | |
| 366 | const SECURITY_CORRECTION_RE = |
| 367 | /\b(?:don'?t|do not|never)\b[^.]{0,30}\b(?:leak|expose|commit|hardcode|hard[- ]?code|push|publish|paste|embed|inline|bake|put|write|store|save)\b[^.]{0,30}\b(?:secret|secrets|token|tokens|key|keys|credential|credentials|password|passwords|env|api)\b|\b(?:rotate|revoke|regenerate|invalidate)\b[^.]{0,25}\b(?:that|the|this|those|your|my)?\s*(?:secret|token|key|credential|password|pat|api[- ]?key|access token)\b|\bthat'?s? (?:a|the|my|our) (?:secret|credential|api[- ]?key|token|password)\b|\b(?:revert|undo|roll ?back)\b[^.]{0,25}\b(?:the|that|those)?\s*(?:auth|security|permission|access[- ]?control|rbac|credential)\b|\b(?:you|it)\b[^.]{0,20}\b(?:leaked|exposed|hardcoded|hard[- ]?coded|committed)\b[^.]{0,25}\b(?:secret|token|key|credential|password|env)\b|\b(?:don'?t|do not|never)\b[^.]{0,30}\b(?:make|leave|set|keep|expose|open)\b[^.]{0,25}\b(?:public|world[- ]?readable|publicly|wide[- ]?open|accessible to (?:everyone|all|the (?:public|world)))\b|\block (?:it|this|that|the bucket|things?) down\b/i; |
| 368 | |
| 369 | function hasSecurityCorrection(text) { |
| 370 | return typeof text === 'string' && text.length <= 4000 && SECURITY_CORRECTION_RE.test(text); |
| 371 | } |
| 372 | |
| 373 | const TOOL_ACTION_REDIRECT_RE = |
| 374 | /\buse\b[^.]{0,30}\b(?:the\s+)?(?:Edit|Write|Read|Bash|Glob|Grep|NotebookEdit|MultiEdit|Task|Search|Replace|Apply\s*Patch|Patch|str_replace\w*|apply_patch)\b(?:\s+(?:tool|command|function|action))?[^.]{0,40}\b(?:instead|rather than|not\b)|\b(?:instead of|rather than)\b[^.]{0,30}\buse\b[^.]{0,30}\b(?:the\s+)?(?:Edit|Write|Read|Bash|Glob|Grep|NotebookEdit|MultiEdit|Task|Search|Replace|Apply\s*Patch|Patch)\b|\bswitch to\b[^.]{0,20}\b(?:the\s+)?(?:Edit|Write|Read|Bash|Glob|Grep|NotebookEdit|MultiEdit|Task)\b(?:\s+(?:tool|command|action))?/i; |
| 375 | function hasToolActionRedirectRemedy(text) { |
| 376 | return typeof text === 'string' && text.length <= 4000 && TOOL_ACTION_REDIRECT_RE.test(text); |
| 377 | } |
| 378 | |
| 379 | export function classifySecuritySurface(file) { |
| 380 | if (!file) return null; |
| 381 | for (const rule of SECURITY_SURFACE_RULES) { |
| 382 | if (rule.re.test(file)) return rule.surface; |
| 383 | } |
| 384 | return null; |
| 385 | } |
| 386 | |
| 387 | export function isRiskyCommand(command) { |
| 388 | return typeof command === 'string' && RISKY_CMD_RE.test(command); |
| 389 | } |
| 390 | |
| 391 | export function mentionsTestSkip(text) { |
| 392 | return ( |
| 393 | typeof text === 'string' && |
| 394 | text.length <= 4000 && |
| 395 | (TEST_SKIP_RE.test(text) || TEST_SKIP_API_RE.test(text)) |
| 396 | ); |
| 397 | } |
| 398 | |
| 399 | function securityActions(node) { |
| 400 | const out = []; |
| 401 | let credMishandle = null; |
| 402 | for (const a of node.actions || []) { |
| 403 | const scan = `${a.narration || ''} ${a.command || ''} ${a.input || ''}`; |
| 404 | const clause = credentialMishandlingClause(scan); |
| 405 | if (clause) { credMishandle = { action: a, clause }; break; } |
| 406 | } |
| 407 | if (credMishandle) { |
| 408 | out.push({ |
| 409 | action: credMishandle.action, |
| 410 | kind: 'credential-mishandling', |
| 411 | strong: true, |
| 412 | evidence: credMishandle.clause, |
| 413 | }); |
| 414 | } |
| 415 | for (const a of node.actions || []) { |
| 416 | const body = `${a.command || ''} ${a.input || ''}`; |
| 417 | const kinds = []; |
| 418 | if (SECRET_CONTENT_RE.test(body) || isSecretByValue(body) || isConfigSurfaceSecret(body, a.file)) kinds.push({ kind: 'credential', strong: true }); |
| 419 | if (a.file && isCredentialFile(a.file)) kinds.push({ kind: 'file', strong: true }); |
| 420 | if (isPublicExposure(body) || ACCESS_CONTROL_CONTENT_RE.test(body)) { |
| 421 | kinds.push({ kind: 'access-control', strong: true }); |
| 422 | } |
| 423 | if (a.command && RISKY_CMD_RE.test(a.command)) kinds.push({ kind: 'risky-command', strong: false }); |
| 424 | if (ACCESS_CONTROL_WEAK_RE.test(body) && !kinds.some((k) => k.kind === 'access-control')) { |
| 425 | kinds.push({ kind: 'access-control', strong: false, weak: true }); |
| 426 | } |
| 427 | if (isSafetyGateWeakening(body)) { |
| 428 | kinds.push({ kind: 'safety-gate-weakening', strong: false, weak: true }); |
| 429 | } |
| 430 | for (const k of kinds) out.push({ action: a, ...k }); |
| 431 | } |
| 432 | return out; |
| 433 | } |
| 434 | |
| 435 | const CONTENT_ANCHORED_KINDS = new Set([ |
| 436 | 'credential', 'credential-mishandling', 'access-control', 'safety-gate-weakening', |
| 437 | ]); |
| 438 | function isContentAnchoredSecurity(secActs) { |
| 439 | return Array.isArray(secActs) && secActs.some((s) => CONTENT_ANCHORED_KINDS.has(s.kind)); |
| 440 | } |
| 441 | function isNamedFileOrRiskyOnly(secActs) { |
| 442 | if (!Array.isArray(secActs) || !secActs.length) return false; |
| 443 | return secActs.every((s) => s.kind === 'file' || s.kind === 'risky-command'); |
| 444 | } |
| 445 | |
| 446 | const SECURITY_STRONG_BASE = 0.95; |
| 447 | const SECURITY_WEAK_BASE = 0.84; |
| 448 | |
| 449 | function scoreSecurity({ secActs, surface, humanCorrection }) { |
| 450 | const signals = []; |
| 451 | const strongActs = secActs.filter((s) => s.strong); |
| 452 | const weakActs = secActs.filter((s) => !s.strong); |
| 453 | const hasStrong = strongActs.length > 0; |
| 454 | const hasWeakKeywordOnly = !hasStrong && secActs.some((s) => s.weak); |
| 455 | |
| 456 | if (strongActs.some((s) => s.kind === 'credential')) signals.push('strong credential content'); |
| 457 | if (strongActs.some((s) => s.kind === 'credential-mishandling')) signals.push('credential mishandling'); |
| 458 | if (strongActs.some((s) => s.kind === 'file')) signals.push('credential filename'); |
| 459 | if (strongActs.some((s) => s.kind === 'access-control')) signals.push('access-control command'); |
| 460 | if (weakActs.some((s) => s.kind === 'risky-command')) signals.push('risky command'); |
| 461 | if (weakActs.some((s) => s.weak && s.kind === 'safety-gate-weakening')) signals.push('safety-gate weakening'); |
| 462 | if (weakActs.some((s) => s.weak && s.kind !== 'safety-gate-weakening')) signals.push('access-control keyword'); |
| 463 | if (surface) signals.push(`security surface (${surface})`); |
| 464 | if (humanCorrection) signals.push('human security correction'); |
| 465 | |
| 466 | const corroboration = Math.max(0, signals.length - 1); |
| 467 | |
| 468 | let tier; |
| 469 | let base; |
| 470 | if (hasStrong) { |
| 471 | tier = 'verified'; |
| 472 | base = SECURITY_STRONG_BASE; |
| 473 | } else if (hasWeakKeywordOnly) { |
| 474 | const cosignal = Boolean(surface) || humanCorrection || weakActs.some((s) => s.kind === 'risky-command'); |
| 475 | if (cosignal) { |
| 476 | tier = 'high'; |
| 477 | base = SECURITY_WEAK_BASE; |
| 478 | } else { |
| 479 | tier = 'inferred'; |
| 480 | base = 0.62; |
| 481 | } |
| 482 | } else { |
| 483 | tier = 'high'; |
| 484 | base = SECURITY_WEAK_BASE; |
| 485 | } |
| 486 | |
| 487 | const ceiling = tier === 'verified' ? 0.95 : tier === 'high' ? 0.9 : 0.7; |
| 488 | const confidence = Math.min(ceiling, Math.round((base + 0.02 * corroboration) * 100) / 100); |
| 489 | return { tier, confidence, signals }; |
| 490 | } |
| 491 | |
| 492 | function fileHint(node) { |
| 493 | for (const a of node.actions || []) { |
| 494 | if (a.file) return a.file; |
| 495 | } |
| 496 | const text = String(node.text || ''); |
| 497 | const m = text.match(/\b([\w./\\-]+\.[a-z0-9]{1,5})\b/i) || text.match(/\b([A-Za-z]:[\\/][^\s"']+)/); |
| 498 | return m ? m[1] : null; |
| 499 | } |
| 500 | |
| 501 | const DESTRUCTIVE_DATA_VERB_RE = |
| 502 | /\b(?:drop(?:s|ped|ping)?|truncat(?:e|es|ed|ing)|delete[sd]?\s+from|wip(?:e|es|ed|ing)|blew\s+away|blow\s+away|overwrote|overwritten|overwrit(?:e|es|ing)|reset\s+--hard|recreate[sd]?\s+from\s+scratch|nuk(?:e|es|ed|ing)|\brm\b)\b/i; |
| 503 | const PERSISTENT_DATA_NOUN_RE = |
| 504 | /\b(?:seed[s]?|fixtures?|migrations?|tables?|schema|database|\bdb\b|volume[s]?)\b/i; |
| 505 | const DATA_RECOVERY_CUE_RE = |
| 506 | /\b(?:restore[sd]?|restoring|recover(?:s|ed|ing)?|undo|revert(?:s|ed|ing)?|roll\s?back|non[\s-]?destructive|get\s+(?:it|them|those)\s+back|bring\s+(?:it|them)\s+back|put\s+(?:it|them)\s+back|re[\s-]?seed)\b/i; |
| 507 | const FUTURE_INTENT_RE = |
| 508 | /\b(?:i'?ll|i\s+will|i'?m\s+going\s+to|gonna|going\s+to|let\s+me|we'?ll|we\s+will|should\s+i|plan\s+to|next\s+i'?ll)\b/i; |
| 509 | const HISTORICAL_DESTRUCTIVE_RE = |
| 510 | /\b(?:ages\s+ago|long\s+ago|years?\s+ago|back\s+then|in\s+the\s+past|already\s+(?:gone|removed|dropped)|historically)\b/i; |
| 511 | |
| 512 | function isDestructiveDataOp(node) { |
| 513 | const text = String(node.text || ''); |
| 514 | const body = (node.actions || []).map((a) => `${a.narration || ''} ${a.command || ''} ${a.input || ''}`).join(' '); |
| 515 | const scan = `${text} ${body}`; |
| 516 | if (scan.length > WORDING_SCAN_MAX_CHARS * 2) return null; |
| 517 | if (!DESTRUCTIVE_DATA_VERB_RE.test(scan)) return null; |
| 518 | if (!PERSISTENT_DATA_NOUN_RE.test(scan)) return null; |
| 519 | if (!DATA_RECOVERY_CUE_RE.test(scan)) return null; |
| 520 | if (FIGURATIVE_DESTRUCTIVE_RE.test(scan) || NOT_AGENT_DISCLAIMER_RE.test(scan)) return null; |
| 521 | if (HISTORICAL_DESTRUCTIVE_RE.test(scan)) return null; |
| 522 | const destClause = clauseSplit(scan).find((c) => DESTRUCTIVE_DATA_VERB_RE.test(c) && PERSISTENT_DATA_NOUN_RE.test(c)); |
| 523 | if (destClause && FUTURE_INTENT_RE.test(destClause) && !DATA_RECOVERY_CUE_RE.test(destClause)) return null; |
| 524 | return { |
| 525 | confidence: 0.9, |
| 526 | tier: 'verified', |
| 527 | summary: 'Persistent data (seed/fixtures/migration) was destructively wiped and had to be restored; make migrations additive and preserve seed data.', |
| 528 | }; |
| 529 | } |
| 530 | |
| 531 | const APPROACH_REVERSAL_RE = |
| 532 | /\b(?:nix|scrap|ditch|drop|abandon|back\s+out|rip\s+(?:it|that|this)\s+out|don'?t\s+go\s+(?:down|with)|not\s+go\s+down|wrong\s+(?:direction|approach|road|track)|back\s+up|wrong\s+way|go\s+(?:a\s+)?different\s+(?:way|direction|route)|switch\s+to|use\s+.{0,40}\binstead\b|instead\s+of|rather\s+than|let'?s\s+not\b|reverse\b|revert(?:ing)?\b)\b/i; |
| 533 | const APPROACH_INTRODUCE_RE = |
| 534 | /\b(?:custom|use\s+(?:a|an|the)|back(?:ed|ing)?\s+(?:it|the\s+\w+)?\s*with|switch(?:ed|ing)?\s+to|go\s+with|implement(?:ing)?\s+(?:a|an|the)|build(?:ing)?\s+(?:a|an|the)|approach|registry|optimizer|index|trie|parser|scheduler|pipeline|cache|engine|adapter|strategy|algorithm|structure)\b/i; |
| 535 | |
| 536 | const REVERSAL_VERB_TOKENS = new Set([ |
| 537 | 'nix', 'scrap', 'ditch', 'drop', 'abandon', 'back', 'out', 'wrong', 'direction', 'approach', |
| 538 | 'road', 'track', 'instead', 'rather', 'switch', 'reverse', 'revert', 'reverting', 'different', |
| 539 | 'route', 'way', 'down', 'with', 'use', 'using', 'lets', 'just', 'hold', 'shape', 'right', |
| 540 | ]); |
| 541 | function approachTokens(text) { |
| 542 | const out = new Set(); |
| 543 | for (const w of String(text || '').toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) || []) { |
| 544 | if (STOPWORDS.has(w) || REVERSAL_VERB_TOKENS.has(w)) continue; |
| 545 | out.add(w); |
| 546 | } |
| 547 | return out; |
| 548 | } |
| 549 | |
| 550 | function abandonedBranch(node, priorNode) { |
| 551 | if (!priorNode) return null; |
| 552 | const text = String(node.text || ''); |
| 553 | if (!text || text.length > WORDING_SCAN_MAX_CHARS) return null; |
| 554 | const isCorrection = |
| 555 | node.kind === 'correction' || |
| 556 | (Array.isArray(node.rejections) && node.rejections.some((r) => r.kind === 'user_text_decline')); |
| 557 | if (!isCorrection) return null; |
| 558 | if (FIGURATIVE_DESTRUCTIVE_RE.test(text) || NOT_AGENT_DISCLAIMER_RE.test(text)) return null; |
| 559 | if (SCOPE_DRIFT_HINT.test(text)) return null; |
| 560 | if (!APPROACH_REVERSAL_RE.test(text)) return null; |
| 561 | const priorNarration = (priorNode.actions || []) |
| 562 | .map((a) => a.narration || '') |
| 563 | .filter(Boolean) |
| 564 | .join(' '); |
| 565 | if (!priorNarration || !APPROACH_INTRODUCE_RE.test(priorNarration)) return null; |
| 566 | const priorTok = approachTokens(priorNarration); |
| 567 | if (!priorTok.size) return null; |
| 568 | const shared = [...approachTokens(text)].find((t) => priorTok.has(t)); |
| 569 | if (!shared) return null; |
| 570 | return { |
| 571 | confidence: 0.78, |
| 572 | tier: 'high', |
| 573 | token: shared, |
| 574 | evidence: `Prior approach abandoned after reversal, introduced as "${quote(priorNarration)}", reversed by: "${quote(text)}"`, |
| 575 | summary: `The "${shared}" approach branch was abandoned after the user navigated away: "${truncate(priorNarration, 110)}".`, |
| 576 | }; |
| 577 | } |
| 578 | |
| 579 | function badPathEpisode(node) { |
| 580 | const text = String(node.text || ''); |
| 581 | if (text.length > WORDING_SCAN_MAX_CHARS) return null; |
| 582 | const destructive = DESTRUCTIVE_RE.test(text); |
| 583 | const recovery = RECOVERY_RE.test(text); |
| 584 | if (!destructive && !recovery) return null; |
| 585 | if (FIGURATIVE_DESTRUCTIVE_RE.test(text) || NOT_AGENT_DISCLAIMER_RE.test(text)) return null; |
| 586 | if (!destructive && recovery && !APOLOGY_RE.test(text)) return null; |
| 587 | const target = fileHint(node); |
| 588 | const where = target ? `\`${truncate(String(target), 70)}\`` : 'a file'; |
| 589 | const tail = recovery |
| 590 | ? ' and had to be recovered; guard against destructive file operations.' |
| 591 | : ' was reported as lost or broken; guard against destructive file operations.'; |
| 592 | return { |
| 593 | confidence: destructive && recovery ? 0.9 : 0.75, |
| 594 | tier: destructive && recovery ? 'verified' : 'high', |
| 595 | summary: `${where} was deleted or damaged${tail}`, |
| 596 | }; |
| 597 | } |
| 598 | |
| 599 | export function analyzeTree(tree) { |
| 600 | if (tree.analysis) return tree.analysis; |
| 601 | _tokenCache = new WeakMap(); |
| 602 | const modelsSeen = new Set(); |
| 603 | let thinkingBlocks = 0; |
| 604 | for (const node of tree.nodes) { |
| 605 | node.failureSignals = []; |
| 606 | node.evalCandidate = false; |
| 607 | node.lessonIds = []; |
| 608 | node.model = (node.actions || []).map((a) => a.model).find(Boolean) || null; |
| 609 | for (const a of node.actions || []) if (a.model) modelsSeen.add(a.model); |
| 610 | thinkingBlocks += node.thinking || 0; |
| 611 | } |
| 612 | |
| 613 | const failures = []; |
| 614 | const correctionChains = []; |
| 615 | const lessons = []; |
| 616 | const evalCandidates = []; |
| 617 | |
| 618 | const pad = (n) => String(n).padStart(3, '0'); |
| 619 | const uniq = (arr) => [...new Set(arr.filter(Boolean))]; |
| 620 | const failureByKey = new Map(); |
| 621 | const lessonByType = new Map(); |
| 622 | const evalByType = new Map(); |
| 623 | |
| 624 | const linkChain = (type, confidence, failureNode, correctionNode, resolvedNode, summary) => { |
| 625 | if (!correctionNode || correctionNode.id === failureNode.id) return; |
| 626 | if (!afterFailure(correctionNode, failureNode)) return; |
| 627 | const resolved = resolvedNode && afterFailure(resolvedNode, failureNode) ? resolvedNode : null; |
| 628 | if (correctionChains.some((c) => c.failureNodeId === failureNode.id && c.correctionNodeId === correctionNode.id)) { |
| 629 | return; |
| 630 | } |
| 631 | correctionChains.push({ |
| 632 | id: `chain_${pad(correctionChains.length + 1)}`, |
| 633 | failureNodeId: failureNode.id, |
| 634 | correctionNodeId: correctionNode.id, |
| 635 | resolvedNodeId: resolved?.id || null, |
| 636 | failureType: type, |
| 637 | confidence: confidenceLabel(confidence), |
| 638 | summary, |
| 639 | }); |
| 640 | }; |
| 641 | |
| 642 | const addFailure = ({ type, confidence, tier = 'inferred', failureNode, correctionNode, resolvedNode, evidence, summary, suppressLesson = false, lessonCorrectionExtra = '' }) => { |
| 643 | if (!FAILURE_TYPES.has(type) || !failureNode) return null; |
| 644 | if (correctionNode && correctionNode.id === failureNode.id) correctionNode = null; |
| 645 | if (correctionNode && !afterFailure(correctionNode, failureNode)) correctionNode = null; |
| 646 | if (resolvedNode && !afterFailure(resolvedNode, failureNode)) resolvedNode = null; |
| 647 | const model = failureNode.model || null; |
| 648 | |
| 649 | const ids = uniq([failureNode.id, correctionNode?.id, resolvedNode?.id]); |
| 650 | const key = `${type}:${failureNode.id}`; |
| 651 | const existing = failureByKey.get(key); |
| 652 | if (existing) { |
| 653 | if (confidence > existing.confidence) existing.confidence = confidence; |
| 654 | if (tierRank(tier) > tierRank(existing.tier)) existing.tier = tier; |
| 655 | const lr = lessonByType.get(type); |
| 656 | if (lr) lr.nodeIds = uniq([...lr.nodeIds, failureNode.id]); |
| 657 | const er = evalByType.get(evalTypeFor(type)); |
| 658 | if (er) er.sourceNodeIds = uniq([...er.sourceNodeIds, ...ids]); |
| 659 | if (correctionNode && !existing.correctedByNodeId) existing.correctedByNodeId = correctionNode.id; |
| 660 | linkChain(type, confidence, failureNode, correctionNode, resolvedNode, summary); |
| 661 | return existing; |
| 662 | } |
| 663 | |
| 664 | const correctionText = !REFUSAL_INPUT_TYPES.has(type) |
| 665 | ? `${correctionNode?.text || ''} ${lessonCorrectionExtra || ''}`.trim() |
| 666 | : ''; |
| 667 | const lesson = lessonFor(type, { evidence, summary, correction: correctionText }); |
| 668 | let lessonRec = lessonByType.get(type); |
| 669 | if (!suppressLesson) { |
| 670 | if (!lessonRec) { |
| 671 | lessonRec = { id: `lesson_${pad(lessons.length + 1)}`, title: lesson.title, nodeIds: [failureNode.id], text: lesson.text }; |
| 672 | lessons.push(lessonRec); |
| 673 | lessonByType.set(type, lessonRec); |
| 674 | } else { |
| 675 | lessonRec.nodeIds = uniq([...lessonRec.nodeIds, failureNode.id]); |
| 676 | } |
| 677 | } |
| 678 | |
| 679 | const evalType = evalTypeFor(type); |
| 680 | let evalRec = evalByType.get(evalType); |
| 681 | if (!evalRec) { |
| 682 | evalRec = { |
| 683 | id: `eval_${pad(evalCandidates.length + 1)}`, |
| 684 | source: 'treetrace', |
| 685 | type: evalType, |
| 686 | task: evalTaskFor(type), |
| 687 | context: summary, |
| 688 | input: REFUSAL_INPUT_TYPES.has(type) |
| 689 | ? evalTaskFor(type) |
| 690 | : correctionNode |
| 691 | ? `Honor this correction and keep building: "${quote(correctionNode.text)}"` |
| 692 | : `Honor this stated requirement and keep building: "${quote(failureNode.text)}"`, |
| 693 | expected_behavior: expectedBehaviorFor(type), |
| 694 | failure_mode: failureModeFor(type), |
| 695 | sourceNodeIds: ids, |
| 696 | }; |
| 697 | evalCandidates.push(evalRec); |
| 698 | evalByType.set(evalType, evalRec); |
| 699 | } else { |
| 700 | evalRec.sourceNodeIds = uniq([...evalRec.sourceNodeIds, ...ids]); |
| 701 | } |
| 702 | |
| 703 | failureNode.failureSignals.push({ |
| 704 | type, |
| 705 | tier, |
| 706 | confidence, |
| 707 | model, |
| 708 | evidence, |
| 709 | resolvedBy: correctionNode?.id || resolvedNode?.id || null, |
| 710 | }); |
| 711 | failureNode.evalCandidate = true; |
| 712 | if (lessonRec) failureNode.lessonIds.push(lessonRec.id); |
| 713 | |
| 714 | const failure = { |
| 715 | id: `failure_${pad(failures.length + 1)}`, |
| 716 | type, |
| 717 | tier, |
| 718 | confidence, |
| 719 | model, |
| 720 | firstSeenNodeId: failureNode.id, |
| 721 | correctedByNodeId: correctionNode?.id || null, |
| 722 | summary, |
| 723 | evidence, |
| 724 | lesson: suppressLesson ? '' : lesson.text, |
| 725 | evalCandidate: true, |
| 726 | }; |
| 727 | failures.push(failure); |
| 728 | failureByKey.set(key, failure); |
| 729 | linkChain(type, confidence, failureNode, correctionNode, resolvedNode, summary); |
| 730 | return failure; |
| 731 | }; |
| 732 | |
| 733 | const nodeHasModelRefusal = (n) => |
| 734 | Array.isArray(n && n.rejections) && n.rejections.some((r) => r.kind === 'model_refusal'); |
| 735 | const refusalAdjacent = (node) => nodeHasModelRefusal(node) || nodeHasModelRefusal(node && node.parent); |
| 736 | |
| 737 | const securityNodeIds = new Set(); |
| 738 | const securityConcernByKey = new Map(); |
| 739 | const securityConcernByStem = new Map(); |
| 740 | let contentAnchoredRiskFired = false; |
| 741 | let strongHumanCorrectionFired = false; |
| 742 | let firstSecurityNamedFileAllowed = false; |
| 743 | let lastSecurityFinding = null; |
| 744 | let anySecurityFindingFired = false; |
| 745 | tree.nodes.forEach((node, index) => { |
| 746 | if (Array.isArray(node.rejections) && node.rejections.length) { |
| 747 | for (const r of node.rejections) { |
| 748 | const type = REJECTION_KIND_TO_FAILURE_TYPE[r.kind]; |
| 749 | if (!type) continue; |
| 750 | const tier = tierForRejection(r.confidence || 0); |
| 751 | const ev = r.evidence |
| 752 | ? `${r.kind} (${r.source || 'tool_result'}): "${quote(r.evidence)}"` |
| 753 | : `${r.kind} (${r.source || 'stop_reason'})`; |
| 754 | const dampDeclineLesson = |
| 755 | type === 'user_rejected_action' && |
| 756 | r.kind === 'user_text_decline' && |
| 757 | !hasToolActionRedirectRemedy(node.text); |
| 758 | addFailure({ |
| 759 | type, |
| 760 | confidence: r.confidence || 0.7, |
| 761 | tier, |
| 762 | failureNode: node, |
| 763 | correctionNode: null, |
| 764 | resolvedNode: null, |
| 765 | evidence: ev, |
| 766 | summary: summarizeRejection(r, node), |
| 767 | suppressLesson: dampDeclineLesson, |
| 768 | }); |
| 769 | } |
| 770 | } |
| 771 | |
| 772 | const secActs = securityActions(node); |
| 773 | const namedFileOnly = isNamedFileOrRiskyOnly(secActs); |
| 774 | const gateSuppressNamedFile = |
| 775 | secActs.length && |
| 776 | namedFileOnly && |
| 777 | firstSecurityNamedFileAllowed && |
| 778 | (contentAnchoredRiskFired || strongHumanCorrectionFired); |
| 779 | if (secActs.length && !gateSuppressNamedFile) { |
| 780 | const surface = uniq((node.actions || []).map((a) => classifySecuritySurface(a.file))).filter(Boolean)[0] || null; |
| 781 | const humanCorrection = |
| 782 | node.kind !== 'correction' ? Boolean(nearestSecurityCorrection(tree.nodes, node)) : false; |
| 783 | const { tier, confidence, signals } = scoreSecurity({ secActs, surface, humanCorrection }); |
| 784 | const targets = uniq( |
| 785 | secActs.map((s) => s.evidence || s.action.file || s.action.command || s.action.input) |
| 786 | ).slice(0, 3); |
| 787 | const kinds = uniq(secActs.map((s) => s.kind)); |
| 788 | const concernKey = securityConcernKey(secActs); |
| 789 | const stemKey = concernKey ? null : credentialStemKey(secActs); |
| 790 | const priorStem = stemKey ? securityConcernByStem.get(stemKey) : null; |
| 791 | const priorConcern = concernKey ? securityConcernByKey.get(concernKey) : priorStem; |
| 792 | if (priorConcern) { |
| 793 | if (confidence > priorConcern.confidence) priorConcern.confidence = confidence; |
| 794 | if (tierRank(tier) > tierRank(priorConcern.tier)) priorConcern.tier = tier; |
| 795 | securityNodeIds.add(node.id); |
| 796 | lastSecurityFinding = priorConcern; |
| 797 | anySecurityFindingFired = true; |
| 798 | } else { |
| 799 | const secCorrection = node.kind === 'correction' ? null : nearestCorrectionAfter(tree.nodes, node); |
| 800 | const secSummary = secCorrection |
| 801 | ? `An agent action touched auth, secrets, or access control near "${truncate(node.title, 90)}"; corrected by: "${quote(secCorrection.text)}".` |
| 802 | : `An agent action touched auth, secrets, or access control near "${truncate(node.title, 90)}".`; |
| 803 | const created = addFailure({ |
| 804 | type: 'security_or_privacy_risk', |
| 805 | confidence, |
| 806 | tier, |
| 807 | failureNode: node, |
| 808 | correctionNode: secCorrection, |
| 809 | resolvedNode: nearestAcceptedAfter(tree.nodes, node, null), |
| 810 | evidence: `Agent action touched ${kinds.join(', ')} [signals: ${signals.join('; ')}]: ${targets.map((t) => `"${truncate(String(t), 80)}"`).join(', ')}`, |
| 811 | summary: secSummary, |
| 812 | lessonCorrectionExtra: siblingSecurityRemedyText(tree.nodes, node, secCorrection), |
| 813 | }); |
| 814 | if (concernKey && created) securityConcernByKey.set(concernKey, created); |
| 815 | else if (stemKey && created) securityConcernByStem.set(stemKey, created); |
| 816 | securityNodeIds.add(node.id); |
| 817 | if (created) { lastSecurityFinding = created; anySecurityFindingFired = true; } |
| 818 | } |
| 819 | if (isContentAnchoredSecurity(secActs)) contentAnchoredRiskFired = true; |
| 820 | if (namedFileOnly) firstSecurityNamedFileAllowed = true; |
| 821 | } else if (node.text.length <= 1200 && SECURITY_INTENT_RE.test(node.text) && !refusalAdjacent(node)) { |
| 822 | addFailure({ |
| 823 | type: 'security_or_privacy_risk', |
| 824 | confidence: 0.7, |
| 825 | tier: 'inferred', |
| 826 | failureNode: node, |
| 827 | correctionNode: null, |
| 828 | resolvedNode: nearestAcceptedAfter(tree.nodes, node, null), |
| 829 | evidence: `User stated a security-sensitive intent: "${quote(node.text)}"`, |
| 830 | summary: `A security-sensitive intent was stated near "${truncate(node.title, 90)}".`, |
| 831 | }); |
| 832 | securityNodeIds.add(node.id); |
| 833 | anySecurityFindingFired = true; |
| 834 | } else if (!nodeHasModelRefusal(node)) { |
| 835 | const narratedClause = narratedSecurityIntent(node); |
| 836 | if (narratedClause) { |
| 837 | const created = addFailure({ |
| 838 | type: 'security_or_privacy_risk', |
| 839 | confidence: 0.7, |
| 840 | tier: 'inferred', |
| 841 | failureNode: node, |
| 842 | correctionNode: null, |
| 843 | resolvedNode: nearestAcceptedAfter(tree.nodes, node, null), |
| 844 | evidence: `Agent narrated a security-sensitive intent: "${truncate(narratedClause, 200)}"`, |
| 845 | summary: `A security-sensitive intent was narrated near "${truncate(node.title, 90)}".`, |
| 846 | }); |
| 847 | if (created) { securityNodeIds.add(node.id); lastSecurityFinding = created; anySecurityFindingFired = true; } |
| 848 | } |
| 849 | } |
| 850 | |
| 851 | if (hasSecurityCorrection(node.text)) { |
| 852 | strongHumanCorrectionFired = true; |
| 853 | if (anySecurityFindingFired) { |
| 854 | if (lastSecurityFinding && lastSecurityFinding.confidence < 0.62) { |
| 855 | lastSecurityFinding.confidence = 0.62; |
| 856 | } |
| 857 | } else { |
| 858 | const prior = nearestFailureTarget(node, tree.nodes); |
| 859 | const anchor = prior ? prior.target : null; |
| 860 | if (anchor && !securityNodeIds.has(anchor.id) && anchor.id !== node.id) { |
| 861 | const created = addFailure({ |
| 862 | type: 'security_or_privacy_risk', |
| 863 | confidence: 0.62, |
| 864 | tier: 'inferred', |
| 865 | failureNode: anchor, |
| 866 | correctionNode: node, |
| 867 | resolvedNode: nearestAcceptedAfter(tree.nodes, anchor, node), |
| 868 | evidence: `Human flagged a security concern about a prior action with no security label [signal: human security correction]: "${quote(node.text)}"`, |
| 869 | summary: `A human security correction was raised near "${truncate(anchor.title, 90)}" with no matching action-level signal.`, |
| 870 | }); |
| 871 | securityNodeIds.add(anchor.id); |
| 872 | if (created) { lastSecurityFinding = created; anySecurityFindingFired = true; } |
| 873 | } |
| 874 | } |
| 875 | } |
| 876 | |
| 877 | if (node.status === 'abandoned') { |
| 878 | addFailure({ |
| 879 | type: 'abandoned_path', |
| 880 | confidence: 0.9, |
| 881 | tier: 'verified', |
| 882 | failureNode: node, |
| 883 | resolvedNode: nearestAcceptedAfter(tree.nodes, node, null), |
| 884 | evidence: `Branch abandoned after prompt: "${quote(node.text)}"`, |
| 885 | summary: `A side path was abandoned: ${truncate(node.title, 120)}`, |
| 886 | }); |
| 887 | return; |
| 888 | } |
| 889 | |
| 890 | const destructive = badPathEpisode(node); |
| 891 | if (destructive) { |
| 892 | addFailure({ |
| 893 | type: 'abandoned_path', |
| 894 | confidence: destructive.confidence, |
| 895 | tier: destructive.tier, |
| 896 | failureNode: node, |
| 897 | resolvedNode: nearestAcceptedAfter(tree.nodes, node, null), |
| 898 | evidence: `User reported a destructive event: "${quote(node.text)}"`, |
| 899 | summary: destructive.summary, |
| 900 | }); |
| 901 | } |
| 902 | |
| 903 | const destructiveData = isDestructiveDataOp(node); |
| 904 | if (destructiveData) { |
| 905 | addFailure({ |
| 906 | type: 'abandoned_path', |
| 907 | confidence: destructiveData.confidence, |
| 908 | tier: destructiveData.tier, |
| 909 | failureNode: node, |
| 910 | resolvedNode: nearestAcceptedAfter(tree.nodes, node, null), |
| 911 | evidence: `Destructive data operation reported (make migrations additive, restore seed data): "${quote(node.text)}"`, |
| 912 | summary: destructiveData.summary, |
| 913 | }); |
| 914 | } |
| 915 | |
| 916 | const priorForBranch = index > 0 ? tree.nodes[index - 1] : null; |
| 917 | const branch = abandonedBranch(node, priorForBranch); |
| 918 | if (branch && priorForBranch && priorForBranch.status !== 'abandoned') { |
| 919 | addFailure({ |
| 920 | type: 'abandoned_path', |
| 921 | confidence: branch.confidence, |
| 922 | tier: branch.tier, |
| 923 | failureNode: priorForBranch, |
| 924 | correctionNode: node, |
| 925 | resolvedNode: nearestAcceptedAfter(tree.nodes, priorForBranch, node), |
| 926 | evidence: branch.evidence, |
| 927 | summary: branch.summary, |
| 928 | }); |
| 929 | } |
| 930 | |
| 931 | const shouldAnalyze = |
| 932 | node.kind === 'correction' || |
| 933 | CORRECTION_HINT.test(node.text) || |
| 934 | FRUSTRATION_HINT.test(node.text) || |
| 935 | PRIVACY_HINT.test(node.text); |
| 936 | if (!shouldAnalyze) return; |
| 937 | if (refusalAdjacent(node)) return; |
| 938 | |
| 939 | const signals = inferSignals(node); |
| 940 | if (!signals.length) return; |
| 941 | |
| 942 | const prior = nearestFailureTarget(node, tree.nodes); |
| 943 | const priorNode = prior ? prior.target : null; |
| 944 | const corroborated = node.kind === 'correction' || (priorNode && sharesEvidence(priorNode, node)); |
| 945 | |
| 946 | let failureNode; |
| 947 | let correctionNode; |
| 948 | let linkage; |
| 949 | if (priorNode && corroborated) { |
| 950 | failureNode = priorNode; |
| 951 | correctionNode = node; |
| 952 | linkage = prior.linkage; |
| 953 | } else if (node.kind === 'correction') { |
| 954 | failureNode = node; |
| 955 | correctionNode = null; |
| 956 | linkage = 'positional'; |
| 957 | } else { |
| 958 | const strongRecall = signals.filter( |
| 959 | (s) => UNCORROBORATED_RECALL_TYPES.has(s.type) && isStrongUncorroboratedSignal(s.type, node.text) |
| 960 | ); |
| 961 | if (strongRecall.length) { |
| 962 | const anchor = priorNode || node; |
| 963 | for (const signal of strongRecall) { |
| 964 | addFailure({ |
| 965 | type: signal.type, |
| 966 | confidence: Math.min(signal.confidence, 0.62), |
| 967 | tier: 'inferred', |
| 968 | failureNode: anchor, |
| 969 | correctionNode: null, |
| 970 | resolvedNode: nearestAcceptedAfter(tree.nodes, anchor, null), |
| 971 | evidence: `User said: "${quote(node.text)}"`, |
| 972 | summary: summarizeFailure(signal.type, anchor, null), |
| 973 | }); |
| 974 | } |
| 975 | } |
| 976 | return; |
| 977 | } |
| 978 | |
| 979 | const resolvedNode = nearestAcceptedAfter(tree.nodes, failureNode, correctionNode); |
| 980 | |
| 981 | for (const signal of signals) { |
| 982 | const tier = correctionNode ? 'confirmed' : 'inferred'; |
| 983 | let confidence = |
| 984 | tier === 'confirmed' ? Math.max(signal.confidence, 0.82) : Math.min(signal.confidence, 0.7); |
| 985 | if (linkage === 'positional') confidence = Math.min(confidence, 0.68); |
| 986 | addFailure({ |
| 987 | type: signal.type, |
| 988 | confidence, |
| 989 | tier: linkage === 'positional' ? 'inferred' : tier, |
| 990 | failureNode, |
| 991 | correctionNode, |
| 992 | resolvedNode, |
| 993 | evidence: `User said: "${quote(node.text)}"`, |
| 994 | summary: summarizeFailure(signal.type, failureNode, correctionNode), |
| 995 | suppressLesson: signal.noLesson, |
| 996 | }); |
| 997 | } |
| 998 | }); |
| 999 | |
| 1000 | const STRUCT_CHAIN_WINDOW = 6; |
| 1001 | const declineRejectionKinds = new Set(['user_declined_tool', 'user_interrupt', 'user_text_decline']); |
| 1002 | const carriesDeclineRejection = (n) => |
| 1003 | Array.isArray(n && n.rejections) && n.rejections.some((r) => declineRejectionKinds.has(r.kind)); |
| 1004 | const isAcceptanceTurn = (n) => |
| 1005 | n.kind !== 'correction' && ACCEPTANCE_RE.test(String(n.text || '')); |
| 1006 | const isRedirectTurn = (n) => |
| 1007 | !isAcceptanceTurn(n) && (n.kind === 'correction' || carriesDeclineRejection(n)); |
| 1008 | const inFailureState = (n) => |
| 1009 | (Array.isArray(n.failureSignals) && n.failureSignals.length > 0) || |
| 1010 | (Array.isArray(n.rejections) && n.rejections.length > 0); |
| 1011 | |
| 1012 | const ordered = tree.nodes |
| 1013 | .filter((n) => n.status !== 'abandoned') |
| 1014 | .slice() |
| 1015 | .sort(orderAfter); |
| 1016 | for (let i = 0; i < ordered.length; i++) { |
| 1017 | const failureNode = ordered[i]; |
| 1018 | if (!inFailureState(failureNode)) continue; |
| 1019 | const end = Math.min(ordered.length, i + 1 + STRUCT_CHAIN_WINDOW); |
| 1020 | for (let j = i + 1; j < end; j++) { |
| 1021 | const candidate = ordered[j]; |
| 1022 | if (candidate.id === failureNode.id) continue; |
| 1023 | if (!isRedirectTurn(candidate)) continue; |
| 1024 | if (!sharesConcreteEvidence(failureNode, candidate)) continue; |
| 1025 | const subject = truncate(failureNode.title || failureNode.text || 'a prior action', 90); |
| 1026 | const summary = `A prior action near "${subject}" was redirected by a later turn: "${quote(candidate.text)}".`; |
| 1027 | linkChain('user_rejected_action', 0.6, failureNode, candidate, null, summary); |
| 1028 | break; |
| 1029 | } |
| 1030 | } |
| 1031 | |
| 1032 | const REDIRECT_REMEDIATION_RE = |
| 1033 | /\b(?:redact(?:s|ed|ing)?|mask(?:s|ed|ing)?|additive|non[\s-]?destructive|restor(?:e|es|ed|ing)|re[\s-]?seed|recover(?:s|ed|ing)?|lock(?:s|ed|ing)?\s*(?:it|this|that|things?|the\s+bucket)?\s*down|lockdown|allow[\s-]?list|fingerprint|rotat(?:e|es|ed|ing)|revok(?:e|es|ed|ing)|workload\s+identity|env\s+var|leave\s+it\s+alone|only\s+(?:a|the)\b)\b/i; |
| 1034 | const isDestructiveRecoverTurn = (n) => { |
| 1035 | const text = String(n.text || ''); |
| 1036 | if (text.length > WORDING_SCAN_MAX_CHARS) return false; |
| 1037 | if (FIGURATIVE_DESTRUCTIVE_RE.test(text) || NOT_AGENT_DISCLAIMER_RE.test(text)) return false; |
| 1038 | return DESTRUCTIVE_RE.test(text) && RECOVERY_RE.test(text); |
| 1039 | }; |
| 1040 | const DATA_ENTITY_RE = |
| 1041 | /\b(?:seed(?:s|\s*data|\s*rows?)?|migrations?|tables?|schemas?|databases?|db|rows?|records?|indexe?s?|columns?|fixtures?|dumps?|backups?|datasets?|collections?)\b/gi; |
| 1042 | const DATA_DESTRUCTIVE_RE = |
| 1043 | /\b(?:blew\s+away|blow\s+away|dropped?|drop[\s-]?and[\s-]?recreate[d]?|truncate[d]?|wiped?|nuked?|deleted?|destroyed?|clobber(?:ed)?|overwr(?:ote|itten))\b/i; |
| 1044 | const DATA_RECOVERY_RE = |
| 1045 | /\b(?:restore|re[\s-]?seed|recover|recreate|bring (?:it|them) back|non[\s-]?destructive|additive|preserve|put (?:it|them) back|undo|revert)\b/i; |
| 1046 | const dataEntities = (s) => { |
| 1047 | const out = new Set(); |
| 1048 | const str = String(s || ''); |
| 1049 | if (!str) return out; |
| 1050 | let m; |
| 1051 | DATA_ENTITY_RE.lastIndex = 0; |
| 1052 | while ((m = DATA_ENTITY_RE.exec(str)) !== null) { |
| 1053 | const tok = m[0].toLowerCase().replace(/\s+/g, ' ').trim(); |
| 1054 | const stem = tok.replace(/^seed.*$/, 'seed').replace(/^migrations?$/, 'migration').replace(/s$/, ''); |
| 1055 | if (stem.length >= 2) out.add(stem); |
| 1056 | } |
| 1057 | return out; |
| 1058 | }; |
| 1059 | const priorActionNarration = (n) => { |
| 1060 | const parts = [String(n.text || '')]; |
| 1061 | for (const a of n.actions || []) if (a.narration) parts.push(String(a.narration)); |
| 1062 | return parts.join(' '); |
| 1063 | }; |
| 1064 | const sharesDataEntity = (prior, redirect) => { |
| 1065 | const re = dataEntities(String(redirect.text || '')); |
| 1066 | if (!re.size) return false; |
| 1067 | const pe = dataEntities(priorActionNarration(prior)); |
| 1068 | for (const e of re) if (pe.has(e)) return true; |
| 1069 | return false; |
| 1070 | }; |
| 1071 | const isDestructiveDataRedirect = (n) => { |
| 1072 | const text = String(n.text || ''); |
| 1073 | if (text.length > WORDING_SCAN_MAX_CHARS) return false; |
| 1074 | if (FIGURATIVE_DESTRUCTIVE_RE.test(text) || NOT_AGENT_DISCLAIMER_RE.test(text)) return false; |
| 1075 | return ( |
| 1076 | dataEntities(text).size > 0 && |
| 1077 | DATA_DESTRUCTIVE_RE.test(text) && |
| 1078 | DATA_RECOVERY_RE.test(text) |
| 1079 | ); |
| 1080 | }; |
| 1081 | const SAME_FILE_CHAIN_WINDOW = 10; |
| 1082 | for (let i = 0; i < ordered.length; i++) { |
| 1083 | const redirect = ordered[i]; |
| 1084 | const text = String(redirect.text || ''); |
| 1085 | if (text.length > WORDING_SCAN_MAX_CHARS) continue; |
| 1086 | const genuineRedirect = |
| 1087 | isRedirectTurn(redirect) || |
| 1088 | carriesDeclineRejection(redirect) || |
| 1089 | isDestructiveRecoverTurn(redirect) || |
| 1090 | isDestructiveDataRedirect(redirect); |
| 1091 | if (!genuineRedirect) continue; |
| 1092 | const remediationRedirect = REDIRECT_REMEDIATION_RE.test(text); |
| 1093 | const start = Math.max(0, i - SAME_FILE_CHAIN_WINDOW); |
| 1094 | for (let j = i - 1; j >= start; j--) { |
| 1095 | const prior = ordered[j]; |
| 1096 | if (prior.id === redirect.id) continue; |
| 1097 | if (prior.status === 'abandoned') continue; |
| 1098 | const priorIsAction = actionFiles(prior).size > 0; |
| 1099 | const concreteFileTie = sharedFiles(prior, redirect) || textNamesActionFile(prior, redirect); |
| 1100 | const remediationTie = remediationRedirect && concreteFileTie; |
| 1101 | const concreteFileActionTie = priorIsAction && concreteFileTie; |
| 1102 | const dataEntityTie = |
| 1103 | priorIsAction && isDestructiveDataRedirect(redirect) && sharesDataEntity(prior, redirect); |
| 1104 | if (!remediationTie && !concreteFileActionTie && !dataEntityTie) continue; |
| 1105 | const subject = truncate(prior.title || prior.text || 'a prior action', 90); |
| 1106 | const summary = `A prior action near "${subject}" was redirected by a later turn: "${quote(text)}".`; |
| 1107 | linkChain('user_rejected_action', 0.6, prior, redirect, null, summary); |
| 1108 | break; |
| 1109 | } |
| 1110 | } |
| 1111 | |
| 1112 | for (const failure of failures) { |
| 1113 | if (!failure.lesson) continue; |
| 1114 | const rec = lessonByType.get(failure.type); |
| 1115 | if (rec && rec.text && rec.text !== failure.lesson) failure.lesson = rec.text; |
| 1116 | } |
| 1117 | |
| 1118 | const topFailureTypes = countTypes(failures); |
| 1119 | tree.analysis = { |
| 1120 | schemaVersion: SCHEMA_VERSION, |
| 1121 | summary: { |
| 1122 | totalFailureSignals: failures.length, |
| 1123 | topFailureTypes, |
| 1124 | tierCounts: countTiers(failures), |
| 1125 | models: [...modelsSeen], |
| 1126 | thinkingBlocks, |
| 1127 | correctionChains: correctionChains.length, |
| 1128 | evalCandidates: evalCandidates.length, |
| 1129 | lessons: lessons.length, |
| 1130 | }, |
| 1131 | failures, |
| 1132 | correctionChains, |
| 1133 | lessons, |
| 1134 | evalCandidates, |
| 1135 | }; |
| 1136 | return tree.analysis; |
| 1137 | } |
| 1138 | |
| 1139 | export function renderFailuresJson(tree, opts = {}) { |
| 1140 | const analysis = analyzeTree(tree); |
| 1141 | return { |
| 1142 | schemaVersion: SCHEMA_VERSION, |
| 1143 | project: projectBlock(opts), |
| 1144 | summary: analysis.summary, |
| 1145 | failures: analysis.failures, |
| 1146 | correctionChains: analysis.correctionChains, |
| 1147 | }; |
| 1148 | } |
| 1149 | |
| 1150 | export function renderRejectionsJson(tree, opts = {}) { |
| 1151 | analyzeTree(tree); |
| 1152 | const out = []; |
| 1153 | const byKind = Object.create(null); |
| 1154 | for (const node of tree.nodes) { |
| 1155 | if (!Array.isArray(node.rejections) || !node.rejections.length) continue; |
| 1156 | for (const r of node.rejections) { |
| 1157 | out.push({ |
| 1158 | nodeId: node.id, |
| 1159 | kind: r.kind, |
| 1160 | source: r.source || null, |
| 1161 | confidence: r.confidence, |
| 1162 | toolUseId: r.toolUseId || null, |
| 1163 | tool: r.tool || null, |
| 1164 | ts: r.ts || node.ts || null, |
| 1165 | evidence: r.evidence || null, |
| 1166 | }); |
| 1167 | byKind[r.kind] = (byKind[r.kind] || 0) + 1; |
| 1168 | } |
| 1169 | } |
| 1170 | out.sort((a, b) => { |
| 1171 | const ta = a.ts ? Date.parse(a.ts) : NaN; |
| 1172 | const tb = b.ts ? Date.parse(b.ts) : NaN; |
| 1173 | if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb; |
| 1174 | return (a.nodeId || '').localeCompare(b.nodeId || ''); |
| 1175 | }); |
| 1176 | return { |
| 1177 | schemaVersion: SCHEMA_VERSION, |
| 1178 | project: projectBlock(opts), |
| 1179 | summary: { |
| 1180 | total: out.length, |
| 1181 | byKind: { ...byKind }, |
| 1182 | }, |
| 1183 | rejections: out, |
| 1184 | }; |
| 1185 | } |
| 1186 | |
| 1187 | export function renderLessonsMarkdown(tree, opts = {}) { |
| 1188 | const analysis = analyzeTree(tree); |
| 1189 | const lines = ['# Lessons', '']; |
| 1190 | if (!analysis.lessons.length) { |
| 1191 | lines.push('No high-confidence failure lessons were detected in this session.'); |
| 1192 | lines.push(''); |
| 1193 | return lines.join('\n'); |
| 1194 | } |
| 1195 | analysis.lessons.forEach((lesson) => { |
| 1196 | const ids = lesson.nodeIds; |
| 1197 | const shown = ids.slice(0, 8).join(', '); |
| 1198 | const overflow = ids.length > 8 ? `, +${ids.length - 8} more` : ''; |
| 1199 | lines.push(`- **${escapeMd(lesson.title)}.** ${escapeMd(compactLessonText(lesson.text))} [${shown}${overflow}]`); |
| 1200 | }); |
| 1201 | lines.push(''); |
| 1202 | return lines.join('\n'); |
| 1203 | } |
| 1204 | |
| 1205 | export function renderEvalsJsonl(tree) { |
| 1206 | const analysis = analyzeTree(tree); |
| 1207 | return analysis.evalCandidates.map((e) => JSON.stringify(e)).join('\n') + (analysis.evalCandidates.length ? '\n' : ''); |
| 1208 | } |
| 1209 | |
| 1210 | export function renderMemoryMarkdown(tree, opts = {}) { |
| 1211 | const analysis = analyzeTree(tree); |
| 1212 | const projectName = opts.projectName || 'this project'; |
| 1213 | const nodes = tree.nodes || []; |
| 1214 | const live = (n) => n.status !== 'abandoned'; |
| 1215 | const lines = [`Project: ${escapeMd(projectName)}`, '']; |
| 1216 | |
| 1217 | const constraints = extractConstraints(nodes); |
| 1218 | if (constraints.length) { |
| 1219 | lines.push('## Constraints'); |
| 1220 | for (const label of constraints) lines.push(`- ${escapeMd(truncate(label, 140))}`); |
| 1221 | lines.push(''); |
| 1222 | } |
| 1223 | |
| 1224 | if (analysis.lessons.length) { |
| 1225 | lines.push('## Lessons'); |
| 1226 | for (const lesson of analysis.lessons.slice(0, 8)) { |
| 1227 | const ids = lesson.nodeIds || []; |
| 1228 | const shown = ids.slice(0, 8).join(', '); |
| 1229 | const overflow = ids.length > 8 ? `, +${ids.length - 8} more` : ''; |
| 1230 | const nodeIds = shown ? ` [${shown}${overflow}]` : ''; |
| 1231 | lines.push(`- ${escapeMd(lesson.title)}: ${escapeMd(compactLessonText(lesson.text))}${nodeIds}`); |
| 1232 | } |
| 1233 | lines.push(''); |
| 1234 | } |
| 1235 | |
| 1236 | const badPaths = analysis.failures.filter((f) => f.type === 'abandoned_path').slice(0, 6); |
| 1237 | if (badPaths.length) { |
| 1238 | lines.push('## Bad paths'); |
| 1239 | for (const failure of badPaths) lines.push(`- ${escapeMd(failure.summary)}`); |
| 1240 | lines.push(''); |
| 1241 | } |
| 1242 | |
| 1243 | const security = analysis.failures |
| 1244 | .filter((f) => f.type === 'security_or_privacy_risk') |
| 1245 | .sort((a, b) => tierRank(b.tier) - tierRank(a.tier)) |
| 1246 | .slice(0, 8); |
| 1247 | if (security.length) { |
| 1248 | lines.push('## Security'); |
| 1249 | for (const f of security) { |
| 1250 | const tag = f.tier === 'inferred' ? 'stated intent' : f.tier; |
| 1251 | const nodeId = f.firstSeenNodeId ? ` [${f.firstSeenNodeId}]` : ''; |
| 1252 | lines.push(`- (${tag})${nodeId} ${escapeMd(truncate(compactEvidenceText(f.evidence), 200))}`); |
| 1253 | } |
| 1254 | lines.push(''); |
| 1255 | } |
| 1256 | |
| 1257 | lines.push('## Next'); |
| 1258 | const strategic = nodes.filter( |
| 1259 | (n) => |
| 1260 | live(n) && |
| 1261 | (n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change') && |
| 1262 | isStrategicDirection(n) |
| 1263 | ); |
| 1264 | const latest = latestByTime(strategic); |
| 1265 | if (latest) { |
| 1266 | lines.push(`- Continue: ${escapeMd(truncate(latest.title, 140))}`); |
| 1267 | } else { |
| 1268 | lines.push(`- No open forward direction was stated; resume the goal of ${escapeMd(projectName)} and confirm scope with the user.`); |
| 1269 | } |
| 1270 | const openCorrections = nodes |
| 1271 | .filter((n) => live(n) && n.kind === 'correction' && isStrategicDirection(n)) |
| 1272 | .slice(-3); |
| 1273 | for (const n of openCorrections) lines.push(`- Constraint: ${escapeMd(truncate(n.title, 120))}`); |
| 1274 | lines.push(''); |
| 1275 | |
| 1276 | return lines.join('\n'); |
| 1277 | } |
| 1278 | |
| 1279 | function compactLessonText(text) { |
| 1280 | const normalized = String(text || '').replace(/\s+/g, ' ').trim(); |
| 1281 | const evidenceAt = normalized.indexOf('Specifically:'); |
| 1282 | return evidenceAt === -1 ? normalized : normalized.slice(evidenceAt + 'Specifically:'.length).trim(); |
| 1283 | } |
| 1284 | |
| 1285 | function compactEvidenceText(text) { |
| 1286 | const normalized = String(text || '').replace(/\s+/g, ' ').trim(); |
| 1287 | const quoted = normalized.match(/"[^"]+"/); |
| 1288 | return quoted ? quoted[0] : normalized; |
| 1289 | } |
| 1290 | |
| 1291 | export function latestByTime(nodes) { |
| 1292 | if (!nodes || !nodes.length) return null; |
| 1293 | const timed = nodes.filter((n) => tsOf(n) !== null); |
| 1294 | if (timed.length) { |
| 1295 | return timed.reduce((best, n) => (tsOf(n) >= tsOf(best) ? n : best), timed[0]); |
| 1296 | } |
| 1297 | return nodes[nodes.length - 1]; |
| 1298 | } |
| 1299 | |
| 1300 | export function isStrategicDirection(node) { |
| 1301 | const text = String(node.text || '').trim(); |
| 1302 | if (!text) return false; |
| 1303 | if (REMEDIATION_RE.test(text) || APOLOGY_RE.test(text)) return false; |
| 1304 | const stripped = text.replace(/[\s.!?]+$/g, ''); |
| 1305 | if (stripped.length < 12) return false; |
| 1306 | if (/^(?:yes|yep|yeah|ok|okay|sure|nice|perfect|great|good|lgtm|thanks?|cool|agreed?)\b/i.test(stripped)) { |
| 1307 | if (stripped.length < 40) return false; |
| 1308 | } |
| 1309 | if (/\?\s*$/.test(text) && text.length < 80) return false; |
| 1310 | return true; |
| 1311 | } |
| 1312 | |
| 1313 | function constraintClauses(text) { |
| 1314 | return String(text || '') |
| 1315 | .split(/(?:[.!?\n]+|\s*;\s*|\s+-\s+|,\s+(?=(?:no|don'?t|do not|never|must|always|only|keep|ensure|make sure|avoid|stay)\b))/i) |
| 1316 | .map((s) => s.replace(/\s+/g, ' ').trim()) |
| 1317 | .filter(Boolean); |
| 1318 | } |
| 1319 | |
| 1320 | function constraintPhrase(clause) { |
| 1321 | let phrase = clause; |
| 1322 | const cue = phrase.search( |
| 1323 | /\b(?:no|don'?t|do not|never|must(?: not)?|always|only|make sure|ensure|avoid|keep it|keep the|stay|without)\b/i |
| 1324 | ); |
| 1325 | if (cue > 0) phrase = phrase.slice(cue); |
| 1326 | phrase = phrase.replace(/^(?:and|also|but|so|then|please|okay|ok|yes|lol)\b[\s,]*/i, '').trim(); |
| 1327 | phrase = phrase.replace(/[\s,;:.!?-]+$/g, '').trim(); |
| 1328 | if (phrase.length > CONSTRAINT_CLAUSE_MAX) phrase = truncate(phrase, CONSTRAINT_CLAUSE_MAX); |
| 1329 | return phrase; |
| 1330 | } |
| 1331 | |
| 1332 | function constraintKey(label) { |
| 1333 | return label |
| 1334 | .toLowerCase() |
| 1335 | .replace(/[^a-z0-9 ]+/g, '') |
| 1336 | .split(/\s+/) |
| 1337 | .filter((w) => w.length > 2 && !STOPWORDS.has(w)) |
| 1338 | .sort() |
| 1339 | .join(' '); |
| 1340 | } |
| 1341 | |
| 1342 | function extractConstraintsFromNode(node) { |
| 1343 | const text = node.text || ''; |
| 1344 | if (!text) return []; |
| 1345 | const found = []; |
| 1346 | const seenLocal = new Set(); |
| 1347 | const push = (label, weight) => { |
| 1348 | const key = constraintKey(label); |
| 1349 | if (!key || seenLocal.has(key)) return; |
| 1350 | seenLocal.add(key); |
| 1351 | found.push({ label, key, weight }); |
| 1352 | }; |
| 1353 | |
| 1354 | for (const named of CONSTRAINT_NAMED) { |
| 1355 | if (named.re.test(text)) push(named.label, 3); |
| 1356 | } |
| 1357 | |
| 1358 | for (const clause of constraintClauses(text)) { |
| 1359 | if (found.length >= CONSTRAINT_PER_NODE_CAP) break; |
| 1360 | if (clause.length < 6 || clause.length > 220) continue; |
| 1361 | if (!CONSTRAINT_DIRECTIVE_RE.test(clause)) continue; |
| 1362 | if (CONSTRAINT_DESCRIPTIVE_RE.test(clause)) continue; |
| 1363 | if (/\?\s*$/.test(clause)) continue; |
| 1364 | if (CONSTRAINT_NAMED.some((n) => n.re.test(clause))) continue; |
| 1365 | const phrase = constraintPhrase(clause); |
| 1366 | if (phrase.length < 6) continue; |
| 1367 | push(phrase.charAt(0).toUpperCase() + phrase.slice(1), 1); |
| 1368 | } |
| 1369 | |
| 1370 | return found.slice(0, CONSTRAINT_PER_NODE_CAP); |
| 1371 | } |
| 1372 | |
| 1373 | function extractConstraints(nodes) { |
| 1374 | const byKey = new Map(); |
| 1375 | nodes.forEach((node, order) => { |
| 1376 | if (node.status === 'abandoned') return; |
| 1377 | for (const c of extractConstraintsFromNode(node)) { |
| 1378 | const existing = byKey.get(c.key); |
| 1379 | if (existing) { |
| 1380 | existing.count += 1; |
| 1381 | existing.weight = Math.max(existing.weight, c.weight); |
| 1382 | if (order >= existing.order) { |
| 1383 | existing.order = order; |
| 1384 | if (c.weight >= existing.bestWeight) { |
| 1385 | existing.label = c.label; |
| 1386 | existing.bestWeight = c.weight; |
| 1387 | } |
| 1388 | } |
| 1389 | } else { |
| 1390 | byKey.set(c.key, { label: c.label, count: 1, weight: c.weight, bestWeight: c.weight, order }); |
| 1391 | } |
| 1392 | } |
| 1393 | }); |
| 1394 | return [...byKey.values()] |
| 1395 | .sort((a, b) => b.weight - a.weight || b.count - a.count || b.order - a.order) |
| 1396 | .slice(0, CONSTRAINT_LIST_CAP) |
| 1397 | .map((c) => c.label); |
| 1398 | } |
| 1399 | |
| 1400 | function isStrongUncorroboratedSignal(type, text) { |
| 1401 | if (type === 'user_frustration') return STRONG_FRUSTRATION_RE.test(text); |
| 1402 | if (type === 'scope_drift') return /\b(?:scope drift|you (?:went|are going) way out of scope|completely off (?:track|scope)|total scope creep)\b/i.test(text); |
| 1403 | if (type === 'overbuilt_solution') return /\b(?:scrap the (?:whole|entire) web app|you (?:overbought|massively overbuilt)|way too (?:heavy|complex|big))\b/i.test(text); |
| 1404 | return false; |
| 1405 | } |
| 1406 | |
| 1407 | function surplusRemovalRedirect(node, text) { |
| 1408 | if (!SURPLUS_CUE_RE.test(text)) return false; |
| 1409 | const m = REMOVE_COMPONENTS_RE.exec(text); |
| 1410 | if (!m) return false; |
| 1411 | const component = m[1].toLowerCase(); |
| 1412 | const prior = node._priorTokens; |
| 1413 | if (!prior || !prior.tokens || !prior.tokens.size) return false; |
| 1414 | return prior.tokens.has(component); |
| 1415 | } |
| 1416 | |
| 1417 | function inferSignals(node) { |
| 1418 | const text = node.text || ''; |
| 1419 | if (node.kind !== 'correction' && text.length > WORDING_SCAN_MAX_CHARS) { |
| 1420 | return []; |
| 1421 | } |
| 1422 | const matched = new Map(); |
| 1423 | const structuralOrigin = new Set(); |
| 1424 | const consider = (type, confidence) => { |
| 1425 | const prev = matched.get(type); |
| 1426 | if (prev === undefined || confidence > prev) matched.set(type, confidence); |
| 1427 | }; |
| 1428 | |
| 1429 | if (SCOPE_DRIFT_HINT.test(text)) consider('scope_drift', 0.82); |
| 1430 | if (/\b(i said|you forgot|you ignored|you skipped|you missed|i explicitly (?:said|asked))\b/i.test(text)) { |
| 1431 | consider('ignored_constraint', 0.84); |
| 1432 | } |
| 1433 | if (TOOL_HINT.test(text)) consider('dependency_or_environment_mismatch', 0.72); |
| 1434 | if (/\bwrong tool|wrong library|use .* instead\b/i.test(text)) consider('wrong_tool_choice', 0.78); |
| 1435 | if (HALLUCINATION_HINT.test(text)) consider('hallucinated_file_or_api', 0.82); |
| 1436 | if (REPEATED_FIX_HINT.test(text)) consider('repeated_failed_fix', 0.8); |
| 1437 | if (surplusRemovalRedirect(node, text)) { consider('scope_drift', 0.8); structuralOrigin.add('scope_drift'); } |
| 1438 | else if (/\btoo much|overbuilt|scrap .* web app|too heavy\b/i.test(text)) consider('overbuilt_solution', 0.78); |
| 1439 | if (UNDERBUILT_HINT.test(text)) consider('underbuilt_solution', 0.76); |
| 1440 | if (FORMAT_HINT.test(text)) consider('format_violation', 0.68); |
| 1441 | if (FRUSTRATION_HINT.test(text)) consider('user_frustration', 0.72); |
| 1442 | if (!matched.size && node.kind === 'correction' && MISUNDERSTOOD_GOAL_RE.test(text) |
| 1443 | && !REVERSAL_VERB_RE.test(text)) { |
| 1444 | consider('misunderstood_goal', 0.62); |
| 1445 | } |
| 1446 | |
| 1447 | if (!matched.size) return []; |
| 1448 | const out = []; |
| 1449 | for (const type of SIGNAL_PRIORITY) { |
| 1450 | if (type === 'misunderstood_goal') continue; |
| 1451 | if (matched.has(type)) out.push({ type, confidence: matched.get(type), noLesson: structuralOrigin.has(type) }); |
| 1452 | } |
| 1453 | if (!out.length && matched.has('misunderstood_goal')) { |
| 1454 | return [{ type: 'misunderstood_goal', confidence: matched.get('misunderstood_goal') }]; |
| 1455 | } |
| 1456 | return out.slice(0, PROCESS_LABEL_CAP); |
| 1457 | } |
| 1458 | |
| 1459 | function tsOf(node) { |
| 1460 | const t = node && node.ts ? new Date(node.ts).getTime() : NaN; |
| 1461 | return Number.isFinite(t) ? t : null; |
| 1462 | } |
| 1463 | |
| 1464 | function ordinalOf(node) { |
| 1465 | if (!node) return null; |
| 1466 | if (Number.isFinite(node._ord)) return node._ord; |
| 1467 | const m = /(\d+)\s*$/.exec(String(node.id || '')); |
| 1468 | return m ? Number(m[1]) : null; |
| 1469 | } |
| 1470 | |
| 1471 | function afterFailure(candidate, failureNode) { |
| 1472 | const ct = tsOf(candidate); |
| 1473 | const ft = tsOf(failureNode); |
| 1474 | if (ct !== null && ft !== null) return ct >= ft; |
| 1475 | const co = ordinalOf(candidate); |
| 1476 | const fo = ordinalOf(failureNode); |
| 1477 | if (co !== null && fo !== null) return co >= fo; |
| 1478 | return false; |
| 1479 | } |
| 1480 | |
| 1481 | function actionFiles(node) { |
| 1482 | return new Set((node.actions || []).map((a) => a.file).filter(Boolean)); |
| 1483 | } |
| 1484 | |
| 1485 | function sharedFiles(a, b) { |
| 1486 | const fa = actionFiles(a); |
| 1487 | if (!fa.size) return false; |
| 1488 | for (const f of actionFiles(b)) if (fa.has(f)) return true; |
| 1489 | return false; |
| 1490 | } |
| 1491 | |
| 1492 | function actionFileBasenames(node) { |
| 1493 | const out = new Set(); |
| 1494 | for (const f of actionFiles(node)) { |
| 1495 | const base = String(f).split(/[\\/]/).pop(); |
| 1496 | if (base && base.length >= 4) out.add(base.toLowerCase()); |
| 1497 | } |
| 1498 | return out; |
| 1499 | } |
| 1500 | |
| 1501 | function textNamesActionFile(a, b) { |
| 1502 | const check = (x, y) => { |
| 1503 | const bases = actionFileBasenames(x); |
| 1504 | if (!bases.size) return false; |
| 1505 | const text = String(y.text || '').toLowerCase(); |
| 1506 | for (const base of bases) if (text.includes(base)) return true; |
| 1507 | return false; |
| 1508 | }; |
| 1509 | return check(a, b) || check(b, a); |
| 1510 | } |
| 1511 | |
| 1512 | let _tokenCache = new WeakMap(); |
| 1513 | function tokenSet(node) { |
| 1514 | if (!node) return new Set(); |
| 1515 | const cached = _tokenCache.get(node); |
| 1516 | if (cached) return cached; |
| 1517 | const out = new Set(); |
| 1518 | const harvest = (s) => { |
| 1519 | for (const raw of String(s || '').toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g) || []) { |
| 1520 | if (!STOPWORDS.has(raw)) out.add(raw); |
| 1521 | } |
| 1522 | }; |
| 1523 | harvest(node.text); |
| 1524 | for (const a of node.actions || []) { |
| 1525 | if (a.file) harvest(String(a.file).replace(/[\\/.+_-]+/g, ' ')); |
| 1526 | if (a.narration) harvest(a.narration); |
| 1527 | } |
| 1528 | _tokenCache.set(node, out); |
| 1529 | return out; |
| 1530 | } |
| 1531 | |
| 1532 | function tokenOverlap(a, b) { |
| 1533 | const ta = tokenSet(a); |
| 1534 | if (!ta.size) return 0; |
| 1535 | const tb = tokenSet(b); |
| 1536 | let hits = 0; |
| 1537 | for (const t of tb) if (ta.has(t)) hits++; |
| 1538 | return hits; |
| 1539 | } |
| 1540 | |
| 1541 | const SURFACE_TOKENS = new Set([ |
| 1542 | 'auth', 'session', 'login', 'signin', 'signup', 'oauth', 'jwt', 'sso', 'saml', |
| 1543 | 'secret', 'secrets', 'credential', 'credentials', 'password', 'token', 'apikey', |
| 1544 | 'rbac', 'permission', 'permissions', 'middleware', 'crypto', 'encrypt', 'decrypt', |
| 1545 | ]); |
| 1546 | |
| 1547 | function sharedSurfaceToken(a, b) { |
| 1548 | const ta = tokenSet(a); |
| 1549 | const tb = tokenSet(b); |
| 1550 | for (const t of ta) if (SURFACE_TOKENS.has(t) && tb.has(t)) return true; |
| 1551 | return false; |
| 1552 | } |
| 1553 | |
| 1554 | function sharesEvidence(failureNode, candidate) { |
| 1555 | if (sharedFiles(failureNode, candidate)) return true; |
| 1556 | if (textNamesActionFile(failureNode, candidate)) return true; |
| 1557 | if (sharedSurfaceToken(failureNode, candidate)) return true; |
| 1558 | return tokenOverlap(failureNode, candidate) >= 3; |
| 1559 | } |
| 1560 | |
| 1561 | function sharesConcreteEvidence(failureNode, candidate) { |
| 1562 | if (sharedFiles(failureNode, candidate)) return true; |
| 1563 | if (textNamesActionFile(failureNode, candidate)) return true; |
| 1564 | return sharedSurfaceToken(failureNode, candidate); |
| 1565 | } |
| 1566 | |
| 1567 | function nearestFailureTarget(node, nodes) { |
| 1568 | const earlier = nodes.filter( |
| 1569 | (n) => n.status !== 'abandoned' && n.id !== node.id && afterFailure(node, n) |
| 1570 | ); |
| 1571 | if (!earlier.length) return null; |
| 1572 | earlier.sort((a, b) => orderAfter(b, a)); |
| 1573 | const semantic = earlier.find((n) => sharesEvidence(n, node)); |
| 1574 | if (semantic) return { target: semantic, linkage: 'semantic' }; |
| 1575 | if (node.parent && node.parent.status !== 'abandoned' && node.parent.id !== node.id && afterFailure(node, node.parent)) { |
| 1576 | return { target: node.parent, linkage: 'positional' }; |
| 1577 | } |
| 1578 | return { target: earlier[0], linkage: 'positional' }; |
| 1579 | } |
| 1580 | |
| 1581 | const ACCEPTANCE_RE = |
| 1582 | /\b(?:that(?:'?s| is| works| fixed)|works now|looks? good|lgtm|perfect|great|nice|fixed|resolved|that did it|that worked|much better|exactly|correct now)\b/i; |
| 1583 | |
| 1584 | function laterCandidates(nodes, failureNode, anchor, extraExcludeId) { |
| 1585 | return nodes |
| 1586 | .filter((n) => n.status !== 'abandoned' && n.id !== failureNode.id && afterFailure(n, anchor)) |
| 1587 | .filter((n) => !extraExcludeId || n.id !== extraExcludeId) |
| 1588 | .sort(orderAfter); |
| 1589 | } |
| 1590 | |
| 1591 | function orderAfter(a, b) { |
| 1592 | const ta = tsOf(a); |
| 1593 | const tb = tsOf(b); |
| 1594 | if (ta !== null && tb !== null) return ta - tb; |
| 1595 | return (ordinalOf(a) ?? Infinity) - (ordinalOf(b) ?? Infinity); |
| 1596 | } |
| 1597 | |
| 1598 | function nearestAcceptedAfter(nodes, failureNode, correctionNode) { |
| 1599 | const anchor = correctionNode || failureNode; |
| 1600 | const later = laterCandidates(nodes, failureNode, anchor, correctionNode?.id); |
| 1601 | if (!later.length) return null; |
| 1602 | const semantic = later.find((n) => sharesEvidence(failureNode, n)); |
| 1603 | if (semantic) return semantic; |
| 1604 | const accepted = later.find((n) => ACCEPTANCE_RE.test(String(n.text || ''))); |
| 1605 | return accepted || null; |
| 1606 | } |
| 1607 | |
| 1608 | function nearestCorrectionAfter(nodes, failureNode) { |
| 1609 | const later = nodes |
| 1610 | .filter((n) => n.status !== 'abandoned' && n.kind === 'correction' && n.id !== failureNode.id && afterFailure(n, failureNode)) |
| 1611 | .sort(orderAfter); |
| 1612 | if (!later.length) return null; |
| 1613 | return later.find((n) => sharesEvidence(failureNode, n)) || null; |
| 1614 | } |
| 1615 | |
| 1616 | function siblingSecurityRemedyText(nodes, failureNode, primaryCorrection) { |
| 1617 | const parts = []; |
| 1618 | const later = nodes |
| 1619 | .filter((n) => n.status !== 'abandoned' && n.id !== failureNode.id && afterFailure(n, failureNode)) |
| 1620 | .sort(orderAfter) |
| 1621 | .slice(0, 12); |
| 1622 | for (const n of later) { |
| 1623 | if (primaryCorrection && n.id === primaryCorrection.id) continue; |
| 1624 | const text = String(n.text || ''); |
| 1625 | if (!text) continue; |
| 1626 | if (liftSecurityRemedyPhrases(text)) parts.push(text); |
| 1627 | } |
| 1628 | return parts.join(' '); |
| 1629 | } |
| 1630 | |
| 1631 | function nearestSecurityCorrection(nodes, failureNode) { |
| 1632 | const later = nodes |
| 1633 | .filter( |
| 1634 | (n) => |
| 1635 | n.status !== 'abandoned' && |
| 1636 | n.id !== failureNode.id && |
| 1637 | afterFailure(n, failureNode) && |
| 1638 | hasSecurityCorrection(n.text) |
| 1639 | ) |
| 1640 | .sort(orderAfter); |
| 1641 | return later.find((n) => sharesEvidence(failureNode, n)) || null; |
| 1642 | } |
| 1643 | |
| 1644 | function tierRank(tier) { |
| 1645 | return tier === 'verified' ? 4 : tier === 'high' ? 3 : tier === 'confirmed' ? 2 : 1; |
| 1646 | } |
| 1647 | |
| 1648 | function countTiers(failures) { |
| 1649 | const counts = { verified: 0, high: 0, confirmed: 0, inferred: 0 }; |
| 1650 | for (const f of failures) if (counts[f.tier] !== undefined) counts[f.tier]++; |
| 1651 | return counts; |
| 1652 | } |
| 1653 | |
| 1654 | function summarizeFailure(type, failureNode, correctionNode) { |
| 1655 | const subject = truncate(failureNode?.title || 'a previous direction', 90); |
| 1656 | if (!correctionNode) { |
| 1657 | switch (type) { |
| 1658 | case 'security_or_privacy_risk': |
| 1659 | return `A privacy or security boundary was stated as a requirement at "${subject}".`; |
| 1660 | case 'scope_drift': |
| 1661 | return `A scope boundary was stated at "${subject}".`; |
| 1662 | case 'format_violation': |
| 1663 | return `A required output format was stated at "${subject}".`; |
| 1664 | default: |
| 1665 | return `A ${type.replace(/_/g, ' ')} concern was raised at "${subject}".`; |
| 1666 | } |
| 1667 | } |
| 1668 | const correction = truncate(correctionNode?.title || 'a later correction', 90); |
| 1669 | switch (type) { |
| 1670 | case 'ignored_constraint': |
| 1671 | return `A prior direction appears to have ignored a user constraint near "${subject}"; corrected by "${correction}".`; |
| 1672 | case 'scope_drift': |
| 1673 | return `The session drifted from the intended scope near "${subject}"; corrected by: "${quote(correctionNode.text)}".`; |
| 1674 | case 'misunderstood_goal': |
| 1675 | return `The agent appears to have misunderstood the goal near "${subject}"; corrected by: "${quote(correctionNode.text)}".`; |
| 1676 | case 'overbuilt_solution': |
| 1677 | return `The work appears to have overbuilt the requested shape near "${subject}"; corrected by "${correction}".`; |
| 1678 | case 'underbuilt_solution': |
| 1679 | return `The work appears to have underbuilt or skipped expected scope near "${subject}"; corrected by "${correction}".`; |
| 1680 | case 'security_or_privacy_risk': |
| 1681 | return `A privacy or security boundary became important near "${subject}"; reinforced by "${correction}".`; |
| 1682 | case 'user_frustration': |
| 1683 | return `User frustration signaled that the prior path near "${subject}" was not meeting expectations.`; |
| 1684 | case 'repeated_failed_fix': |
| 1685 | return `A fix loop appears to have repeated near "${subject}"; corrected by "${correction}".`; |
| 1686 | default: |
| 1687 | return `A possible ${type.replace(/_/g, ' ')} occurred near "${subject}"; corrected by "${correction}".`; |
| 1688 | } |
| 1689 | } |
| 1690 | |
| 1691 | const SECURITY_REMEDY_PHRASES = [ |
| 1692 | { re: /\bworkload identit(?:y|ies)\b/i, phrase: 'use workload identity' }, |
| 1693 | { re: /\brevok(?:e|es|ed|ing)\b/i, phrase: 'revoke the exposed credential' }, |
| 1694 | { re: /\brotat(?:e|es|ed|ing)\b/i, phrase: 'rotate the exposed credential' }, |
| 1695 | { re: /\b(?:secret(?:s)?\s+(?:store|manager|vault)|vault|secret manager)\b/i, phrase: 'load it from a secret store' }, |
| 1696 | { re: /\benv(?:ironment)?\s*var\w*\b|\benv-?supplied\b|\bfrom (?:an? )?env\b/i, phrase: 'read it from an environment variable outside the tree' }, |
| 1697 | { re: /\ballow[- ]?list\b|\ballowlist\b/i, phrase: 'restrict to an allowlist' }, |
| 1698 | { re: /\bnon[- ]?destructive\b|\badditive\b/i, phrase: 'make the change additive and non-destructive' }, |
| 1699 | ]; |
| 1700 | function liftSecurityRemedyPhrases(body) { |
| 1701 | const text = String(body || ''); |
| 1702 | if (!text) return ''; |
| 1703 | const out = []; |
| 1704 | for (const { re, phrase } of SECURITY_REMEDY_PHRASES) { |
| 1705 | if (re.test(text) && !out.includes(phrase)) out.push(phrase); |
| 1706 | } |
| 1707 | if (!out.length) return ''; |
| 1708 | return `${out.join('; ')}.`; |
| 1709 | } |
| 1710 | |
| 1711 | function lessonFor(type, { evidence = '', summary = '', correction = '' } = {}) { |
| 1712 | const titles = { |
| 1713 | ignored_constraint: 'Preserve explicit constraints', |
| 1714 | misunderstood_goal: 'Re-check the actual goal', |
| 1715 | scope_drift: 'Keep scope boundaries durable', |
| 1716 | wrong_tool_choice: 'Choose tools from the repo context', |
| 1717 | hallucinated_file_or_api: 'Verify files and APIs before acting', |
| 1718 | repeated_failed_fix: 'Break repeated fix loops', |
| 1719 | overbuilt_solution: 'Avoid overbuilding beyond the requested shape', |
| 1720 | underbuilt_solution: 'Do not skip required scope', |
| 1721 | security_or_privacy_risk: 'Treat privacy boundaries as product requirements', |
| 1722 | dependency_or_environment_mismatch: 'Respect the local environment', |
| 1723 | format_violation: 'Preserve requested output formats', |
| 1724 | user_frustration: 'Escalate when user frustration appears', |
| 1725 | abandoned_path: 'Avoid abandoned paths unless explicitly revived', |
| 1726 | user_rejected_action: 'Confirm proposed actions before executing', |
| 1727 | tool_execution_failed: 'Validate tool inputs before executing', |
| 1728 | model_refused: 'Rephrase refused requests instead of repeating them', |
| 1729 | permission_denied: 'Pre-flight check filesystem and shell permissions', |
| 1730 | }; |
| 1731 | const guidance = { |
| 1732 | ignored_constraint: 'Future agents should carry explicit user constraints forward as high-priority requirements.', |
| 1733 | misunderstood_goal: 'Future agents should restate and verify the goal before continuing after a correction.', |
| 1734 | scope_drift: 'Future agents should preserve the corrected scope and avoid adding unrequested product shape.', |
| 1735 | wrong_tool_choice: 'Future agents should prefer tools and dependencies already supported by the repo and environment.', |
| 1736 | hallucinated_file_or_api: 'Future agents should verify that referenced files, commands, and APIs exist before relying on them.', |
| 1737 | repeated_failed_fix: 'Future agents should stop and reassess after repeated failed fixes instead of applying another blind patch.', |
| 1738 | overbuilt_solution: 'Future agents should prefer the smallest implementation that satisfies the corrected product direction.', |
| 1739 | underbuilt_solution: 'Future agents should check that all explicitly requested behavior is represented before claiming completion.', |
| 1740 | security_or_privacy_risk: 'Future agents should not weaken local-first privacy, redaction, or no-network guarantees without explicit approval.', |
| 1741 | dependency_or_environment_mismatch: 'Future agents should validate environment assumptions before choosing dependencies or runtime paths.', |
| 1742 | format_violation: 'Future agents should preserve requested output formats exactly unless the user approves a change.', |
| 1743 | user_frustration: 'Future agents should treat frustration as a signal to slow down, verify assumptions, and correct course.', |
| 1744 | abandoned_path: 'Future agents should avoid resurrecting abandoned branches unless the user explicitly asks for them.', |
| 1745 | user_rejected_action: 'Future agents should not retry a tool action the user just declined without first explaining why the action is still worth taking.', |
| 1746 | tool_execution_failed: 'Future agents should validate command inputs and surface expected errors before running shell or write tools, instead of discovering failures after execution.', |
| 1747 | model_refused: 'Future agents should treat a refusal as a signal to rephrase or descope, not to retry the same request verbatim; if the user confirms the request is legitimate, surface the refusal reason.', |
| 1748 | permission_denied: 'Future agents should pre-flight check that required files, commands, or resources are accessible before attempting an action that needs them.', |
| 1749 | }; |
| 1750 | const base = guidance[type] || 'Future agents should preserve this correction.'; |
| 1751 | const fix = String(correction || '').replace(/\s+/g, ' ').trim(); |
| 1752 | const concrete = fix || String(evidence || summary || '').replace(/\s+/g, ' ').trim(); |
| 1753 | const lead = fix ? 'Specifically, the user directed' : 'Specifically'; |
| 1754 | let remedy = ''; |
| 1755 | if (type === 'security_or_privacy_risk') { |
| 1756 | const lifted = liftSecurityRemedyPhrases(`${correction || ''} ${evidence || ''} ${summary || ''}`); |
| 1757 | if (lifted) { |
| 1758 | remedy = lifted; |
| 1759 | } else { |
| 1760 | const surf = `${evidence || ''} ${summary || ''}`.toLowerCase(); |
| 1761 | if (/access-control|cors|wildcard|public|allow[- ]?origin/.test(surf)) { |
| 1762 | remedy = 'restrict the access-control surface to an allowlist of permitted origins and require auth.'; |
| 1763 | } else if (/credential|secret|password|token|api[- ]?key|access key|\.env|configmap|compose/.test(surf)) { |
| 1764 | remedy = 'load the value from a secret store and rotate the exposed credential.'; |
| 1765 | } |
| 1766 | } |
| 1767 | } |
| 1768 | let text = concrete ? `${base} ${lead}: ${truncate(concrete, 220)}` : base; |
| 1769 | if (remedy && !text.toLowerCase().includes(remedy.slice(0, 24))) text = `${text} Remediation: ${remedy}`; |
| 1770 | return { |
| 1771 | title: titles[type] || 'Preserve the correction', |
| 1772 | text, |
| 1773 | }; |
| 1774 | } |
| 1775 | |
| 1776 | const REFUSAL_INPUT_TYPES = new Set(['model_refused', 'user_rejected_action', 'permission_denied', 'tool_execution_failed']); |
| 1777 | |
| 1778 | function evalTypeFor(type) { |
| 1779 | if (type === 'security_or_privacy_risk') return 'privacy_boundary_preservation'; |
| 1780 | if (type === 'scope_drift' || type === 'overbuilt_solution') return 'scope_drift_detection'; |
| 1781 | if (type === 'ignored_constraint' || type === 'format_violation') return 'constraint_preservation'; |
| 1782 | if (type === 'wrong_tool_choice' || type === 'dependency_or_environment_mismatch') return 'tool_choice_regression'; |
| 1783 | if (type === 'abandoned_path') return 'correction_adherence'; |
| 1784 | if (type === 'user_rejected_action' || type === 'permission_denied') return 'tool_permission_regression'; |
| 1785 | if (type === 'tool_execution_failed') return 'tool_error_recovery'; |
| 1786 | if (type === 'model_refused') return 'refusal_handling'; |
| 1787 | return 'instruction_following_regression'; |
| 1788 | } |
| 1789 | |
| 1790 | function evalTaskFor(type) { |
| 1791 | if (type === 'security_or_privacy_risk') return 'Continue development while preserving privacy and redaction boundaries.'; |
| 1792 | if (type === 'scope_drift') return 'Continue development without drifting outside the corrected scope.'; |
| 1793 | if (type === 'format_violation') return 'Continue development while preserving the requested output format.'; |
| 1794 | if (type === 'user_rejected_action' || type === 'permission_denied') { |
| 1795 | return 'Continue development without re-attempting tool actions the user or environment has just rejected.'; |
| 1796 | } |
| 1797 | if (type === 'tool_execution_failed') return 'Continue development while validating tool inputs before execution.'; |
| 1798 | if (type === 'model_refused') return 'Continue development by rephrasing refused requests rather than repeating them.'; |
| 1799 | return 'Continue development while preserving the corrected direction from the session lineage.'; |
| 1800 | } |
| 1801 | |
| 1802 | function expectedBehaviorFor(type) { |
| 1803 | const common = ['Use the corrected prompt lineage as durable context', 'Do not repeat the documented failure mode']; |
| 1804 | if (type === 'security_or_privacy_risk') return ['Preserve local-first behavior', 'Do not add telemetry or uploads', 'Keep redaction fail-closed', ...common]; |
| 1805 | if (type === 'scope_drift') return ['Stay inside the corrected scope', 'Do not add unrequested product surfaces', ...common]; |
| 1806 | if (type === 'ignored_constraint') return ['Carry explicit user constraints forward', 'Check new work against those constraints', ...common]; |
| 1807 | if (type === 'format_violation') return ['Preserve the requested format', 'Validate generated artifacts', ...common]; |
| 1808 | return common; |
| 1809 | } |
| 1810 | |
| 1811 | function failureModeFor(type) { |
| 1812 | return `Agent repeats ${type.replace(/_/g, ' ')} despite prior correction.`; |
| 1813 | } |
| 1814 | |
| 1815 | function summarizeRejection(r, node) { |
| 1816 | const subject = truncate(node && node.title ? node.title : 'a previous turn', 90); |
| 1817 | switch (r.kind) { |
| 1818 | case 'user_declined_tool': |
| 1819 | return `The user declined a proposed tool action near "${subject}".`; |
| 1820 | case 'user_interrupt': |
| 1821 | return `The user interrupted the agent mid-response near "${subject}".`; |
| 1822 | case 'user_text_decline': |
| 1823 | return `The user explicitly told the agent to stop or not proceed near "${subject}".`; |
| 1824 | case 'tool_execution_error': |
| 1825 | return `A tool execution returned an error near "${subject}".`; |
| 1826 | case 'permission_denied': |
| 1827 | return `A tool action was denied by the environment (permission denied) near "${subject}".`; |
| 1828 | case 'model_refusal': |
| 1829 | return `The model refused to proceed near "${subject}".`; |
| 1830 | default: |
| 1831 | return `A ${r.kind || 'rejection'} was captured near "${subject}".`; |
| 1832 | } |
| 1833 | } |
| 1834 | |
| 1835 | function confidenceLabel(score) { |
| 1836 | if (score >= 0.8) return 'high'; |
| 1837 | if (score >= 0.65) return 'medium'; |
| 1838 | return 'low'; |
| 1839 | } |
| 1840 | |
| 1841 | function countTypes(failures) { |
| 1842 | const counts = new Map(); |
| 1843 | for (const failure of failures) counts.set(failure.type, (counts.get(failure.type) || 0) + 1); |
| 1844 | return [...counts.entries()] |
| 1845 | .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) |
| 1846 | .map(([type, count]) => ({ type, count })); |
| 1847 | } |
| 1848 | |
| 1849 | function projectBlock(opts) { |
| 1850 | return { |
| 1851 | name: opts.projectName || null, |
| 1852 | generatedAt: opts.generatedAt || null, |
| 1853 | }; |
| 1854 | } |
| 1855 | |
| 1856 | function quote(text) { |
| 1857 | return truncate(String(text || '').replace(/\s+/g, ' '), 240).replace(/"/g, '\\"'); |
| 1858 | } |