Zion Boggan
repos/TreeTrace/src/analyze.js
zionboggan.com ↗
1858 lines · javascript
History for this file →
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
}