opencode-dejavu 2.7.0 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +361 -0
- package/README.md +25 -14
- package/command/dejavu.md +26 -0
- package/index.ts +415 -39
- package/package.json +6 -2
- package/scripts/analyze.ts +59 -0
- package/scripts/doctor.ts +505 -0
- package/scripts/githooks/commit-msg +46 -0
- package/scripts/migrate.ts +49 -0
- package/skills/dejavu/SKILL.md +48 -0
- package/src/AGENTS.md +56 -8
- package/src/patterns.ts +680 -36
- package/src/store.ts +794 -181
- package/src/validate.ts +58 -7
package/src/patterns.ts
CHANGED
|
@@ -47,6 +47,26 @@ export function scrubSecrets(text: string): string {
|
|
|
47
47
|
return s
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Terminal control characters (PowerShell VT-colored errors, bells, NULs)
|
|
52
|
+
* carry no signal — persisted they corrupt snippets/corrections with raw
|
|
53
|
+
* escape sequences (`ESC[31;1m...`) and fragmented identities when the
|
|
54
|
+
* coloring varies between runs. ANSI sequences first (they end in a letter,
|
|
55
|
+
* which bare C0 stripping would strand), then all C0 except LF/CR/TAB —
|
|
56
|
+
* those three carry structure (multi-line commands, indentation).
|
|
57
|
+
*/
|
|
58
|
+
export function stripControl(text: string): string {
|
|
59
|
+
return text
|
|
60
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
61
|
+
.replace(/\u001b./g, "")
|
|
62
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The persistence boundary: control-char strip + secret scrub, in one call. */
|
|
66
|
+
export function sanitizeForStore(text: string): string {
|
|
67
|
+
return scrubSecrets(stripControl(text))
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
// --- Normalization -----------------------------------------------------------
|
|
51
71
|
|
|
52
72
|
/**
|
|
@@ -56,12 +76,28 @@ export function scrubSecrets(text: string): string {
|
|
|
56
76
|
* payload instead: same code = same key, different code = different key.
|
|
57
77
|
* Secrets are scrubbed before hashing so they neither persist nor fragment.
|
|
58
78
|
*/
|
|
79
|
+
/**
|
|
80
|
+
* PowerShell shapes included: call operator + QUOTED exe path
|
|
81
|
+
* (`& "C:\...\python.exe" -c ...`) and bare interpreter alike. The quoted
|
|
82
|
+
* path needs the quote in the prefix class and an optional closing quote,
|
|
83
|
+
* otherwise `-c` never lines up and the payload escapes fingerprinting.
|
|
84
|
+
* Leading env assignments (`PYTHONPATH=x python -c ...`) are allowed in the
|
|
85
|
+
* anchor and stay in the prefix — without them the one-liner escaped
|
|
86
|
+
* fingerprinting entirely. Flag alternatives run LONGEST FIRST: regex
|
|
87
|
+
* alternatives are ordered, and `-c` matching inside `-command` swallowed
|
|
88
|
+
* `ommand` into the payload — fragmenting keys across spellings of the same
|
|
89
|
+
* call. `py` is the Windows Python launcher (`py -3 -c ...`).
|
|
90
|
+
*/
|
|
59
91
|
const INTERPRETER_ONELINER =
|
|
60
|
-
/(?:^|[|;&(\n]\s*)(?:\S+[\\/])?(python3?|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?(?:\s+-\w+)*\s+(-
|
|
92
|
+
/(?:^|[|;&(\n]\s*)(?:\w+=\S+\s+)*(?:["']?\S*[\\/])?(python3?|py|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?["']?(?:\s+-\w+)*\s+(-command|-encodedcommand|--eval|-c|-e)\s*/i
|
|
61
93
|
|
|
62
94
|
function hashInterpreterPayload(command: string): string {
|
|
63
95
|
const match = INTERPRETER_ONELINER.exec(command)
|
|
64
96
|
if (!match) return command
|
|
97
|
+
// PowerShell here-string payloads (`@"..."@` / `@'...'@`) — the wrapper
|
|
98
|
+
// markers are part of the payload and hash with it. Previously the `@`
|
|
99
|
+
// markers survived normalization and the quoted body collapsed to <str>,
|
|
100
|
+
// leaving raw code tokens leaking into signatures when quotes unbalanced.
|
|
65
101
|
const payload = command.slice(match.index + match[0].length)
|
|
66
102
|
if (payload.trim() === "") return command
|
|
67
103
|
// Already fingerprinted (re-normalization) — keep the existing token so
|
|
@@ -75,7 +111,40 @@ function hashInterpreterPayload(command: string): string {
|
|
|
75
111
|
// Trim before hashing: trailing whitespace (e.g. a stripped override marker)
|
|
76
112
|
// is not part of the code's identity.
|
|
77
113
|
const fingerprint = createHash("sha1").update(scrubSecrets(payload.trim())).digest("hex").slice(0, 8)
|
|
78
|
-
|
|
114
|
+
// Long PowerShell flags converge to -c: `-command`/`-encodedcommand` are
|
|
115
|
+
// spellings of the same one-liner call — one identity, not three families.
|
|
116
|
+
const prefix = command.slice(0, match.index + match[0].length).replace(/-(?:command|encodedcommand)(\s*)$/i, "-c$1")
|
|
117
|
+
return `${prefix}<code:${fingerprint}>`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Windows wrapper verb: `cmd /c "real command"` — the payload IS the call.
|
|
122
|
+
* Unwrapped, the payload normalizes with its own identity and its real verb
|
|
123
|
+
* stays visible to the diagnostic policy; left wrapped, `/c` becomes `<path>`
|
|
124
|
+
* and the payload becomes `<str>`, so `cmd <path> <str>` matched every cmd
|
|
125
|
+
* invocation on the machine. Recursion terminates: the payload is strictly
|
|
126
|
+
* shorter than the wrapper command.
|
|
127
|
+
*/
|
|
128
|
+
const CMD_WRAPPER = /^cmd(?:\.exe)?\s+(?:\/s\s+)?\/(c|k)\s+/i
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Raw payload of a `cmd /c|/k` wrapper, one wrapper-quote layer removed —
|
|
132
|
+
* null when the command is not wrapped. Shared by normalization (unwrap),
|
|
133
|
+
* segment expansion (inner chains) and override-marker visibility.
|
|
134
|
+
*/
|
|
135
|
+
export function cmdWrapperPayload(command: string): string | null {
|
|
136
|
+
const match = CMD_WRAPPER.exec(command)
|
|
137
|
+
if (!match) return null
|
|
138
|
+
let payload = command.slice(match[0].length).trim()
|
|
139
|
+
if ((payload.startsWith('"') && payload.endsWith('"') && payload.length >= 2) || (payload.startsWith("'") && payload.endsWith("'") && payload.length >= 2)) {
|
|
140
|
+
payload = payload.slice(1, -1).trim()
|
|
141
|
+
}
|
|
142
|
+
return payload === "" ? null : payload
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unwrapCmdWrapper(command: string): string {
|
|
146
|
+
const payload = cmdWrapperPayload(command)
|
|
147
|
+
return payload === null ? command : normalizeCommand(payload)
|
|
79
148
|
}
|
|
80
149
|
|
|
81
150
|
/**
|
|
@@ -84,10 +153,14 @@ function hashInterpreterPayload(command: string): string {
|
|
|
84
153
|
* away so that "same failure, different instance" collapses into one pattern.
|
|
85
154
|
*/
|
|
86
155
|
export function normalizeCommand(command: string): string {
|
|
156
|
+
// Terminal control characters (PowerShell VT colors) carry no identity and
|
|
157
|
+
// fragmented signatures when coloring varied between runs — strip first.
|
|
158
|
+
let s = stripControl(command)
|
|
87
159
|
// CRLF/CR commands (Windows pastes, agent multi-line) normalize to LF —
|
|
88
160
|
// otherwise the same command fragments across line-ending styles.
|
|
89
|
-
|
|
161
|
+
s = s.replace(/\r\n?/g, "\n")
|
|
90
162
|
s = s.replace(COMMENT_LINE, "$1").toLowerCase()
|
|
163
|
+
s = unwrapCmdWrapper(s)
|
|
91
164
|
s = hashInterpreterPayload(s)
|
|
92
165
|
// Quoted spans come out FIRST: they are data, and removing them before the
|
|
93
166
|
// path rules keeps normalization idempotent — a <str> replacement inserts
|
|
@@ -124,7 +197,7 @@ const PARAM_RULES: [RegExp, string][] = [
|
|
|
124
197
|
]
|
|
125
198
|
|
|
126
199
|
export function parameterizeError(text: string): string {
|
|
127
|
-
let s = text.toLowerCase()
|
|
200
|
+
let s = stripControl(text).toLowerCase()
|
|
128
201
|
for (const [rule, token] of PARAM_RULES) {
|
|
129
202
|
s = s.replace(rule, token)
|
|
130
203
|
}
|
|
@@ -142,14 +215,36 @@ export function parameterizeError(text: string): string {
|
|
|
142
215
|
const DIAGNOSTIC_VERBS: RegExp[] = [
|
|
143
216
|
/(^|[\s|;&:])(grep|rg|findstr|select-string)\b/i,
|
|
144
217
|
/\bgit grep\b/i,
|
|
218
|
+
// Read-only git inspectors are diagnostics like `git grep`: their exit 1 is
|
|
219
|
+
// usually a downstream filter finding nothing (`git show … | Select-String`),
|
|
220
|
+
// not a mistake. Real git errors exit >= 2 and still count.
|
|
221
|
+
/\bgit\s+(show|log|status|ls-tree|ls-files|blame|diff)\b/i,
|
|
145
222
|
/(^|[\s|;&:])diff\b/i,
|
|
146
223
|
/\b(pytest|jest|vitest|mocha|cucumbertest)\b/i,
|
|
224
|
+
// npm/yarn/pnpm test / typecheck / lint scripts are iteration work — their
|
|
225
|
+
// exit 1 is "tests failed / types wrong / lint found issues", not an
|
|
226
|
+
// infrastructure error. The second rule covers flags between the package
|
|
227
|
+
// manager and the verb (e.g. `pnpm --filter <pkg> typecheck`).
|
|
228
|
+
/\b(npm|pnpm|yarn) (run )?(test|typecheck|lint)\b/i,
|
|
229
|
+
/\b(npm|pnpm|yarn)\b[^\n;|&]*\b(typecheck|lint)\b/i,
|
|
230
|
+
// `npm run check:*` / `verify:*` scripts are iteration work like test/lint —
|
|
231
|
+
// their exit 1 is "found issues", not an infrastructure error.
|
|
232
|
+
/\b(npm|pnpm|yarn|bun) (run )?(check|verify)\b/i,
|
|
147
233
|
/\bplaywright test\b/i,
|
|
148
234
|
/\bflutter (test|analyze)\b/i,
|
|
149
235
|
/\bdart (analyze|format|fix)\b/i,
|
|
236
|
+
// Iteration runners: `dart run <script>`, `go run|build`, `cargo run|build`
|
|
237
|
+
// fail repeatedly WHILE the agent fixes the code — the failures are the
|
|
238
|
+
// work itself. Blocking them produced arms races (dozens of overrides in
|
|
239
|
+
// production data); they remind but never block, and their exit 1 is the
|
|
240
|
+
// intended "still broken" outcome of iteration.
|
|
241
|
+
/\bdart run\b/i,
|
|
242
|
+
/\bgo (run|build|test|vet)\b/i,
|
|
243
|
+
/\bcargo (run|build|test|clippy)\b/i,
|
|
150
244
|
/\bgradlew\b[^\n;|&]*(test|compilejava|compiletestjava)/i,
|
|
151
245
|
/\b(eslint|prettier --check)\b/i,
|
|
152
246
|
/\btsc\b/i,
|
|
247
|
+
/\bmypy\b/i,
|
|
153
248
|
/\bcurl\b/i,
|
|
154
249
|
/\bls\b/i,
|
|
155
250
|
]
|
|
@@ -162,40 +257,323 @@ export function isDiagnosticSignature(signature: string): boolean {
|
|
|
162
257
|
return isDiagnosticText(signature)
|
|
163
258
|
}
|
|
164
259
|
|
|
165
|
-
/**
|
|
260
|
+
/** Pipeline formatters shape output but are never the failing producer WHEN
|
|
261
|
+
* they are a pipe tail: PowerShell cmdlets don't set `$LASTEXITCODE` (it stays
|
|
262
|
+
* with the producing native command), and unix head/tail/column/uniq tails exit
|
|
263
|
+
* 0 on piped input. So piping a diagnostic into one (`tsc | Select-Object -Last
|
|
264
|
+
* 5`, `vitest | head -5`) must not break the diagnostic's exit-1 immunity.
|
|
265
|
+
* Position matters: a formatter standing alone or as the TERMINAL producer of a
|
|
266
|
+
* sequence (`npm test && tail -5 missing.log`) IS the failing producer — its
|
|
267
|
+
* exit must still count. isIntendedNonzero only grants the transparency to
|
|
268
|
+
* segments splitChainTagged marks as pipe tails. */
|
|
269
|
+
const PIPE_FORMATTERS =
|
|
270
|
+
/^\s*(select-object|sort-object|format-table|format-list|format-wide|format-custom|out-string|out-host|out-null|tee-object|foreach-object|where-object|measure-object|group-object|convertto-json|convertfrom-json|head|tail|column|uniq|tee)\b/i
|
|
271
|
+
/** Navigation changes directory, never the outcome — `cd X && <diagnostic>`
|
|
272
|
+
* must not lose the diagnostic's exit-1 immunity to the `cd` segment. */
|
|
273
|
+
const NAVIGATION_VERBS = /^\s*(cd|set-location|pushd|popd)\b/i
|
|
274
|
+
/** Pure environment assignments (`$env:CI="true"`, `FOO=bar`) and sleeps only
|
|
275
|
+
* prepare the session — they are never the failing producer, so they are
|
|
276
|
+
* transparent to exit-1 immunity and chain attribution like navigation. */
|
|
277
|
+
const ENV_ASSIGNMENT_SEGMENT = /^\s*(?:\$env:)?[a-z_][a-z0-9_]*\s*=\s*(?:"[^"]*"|'[^']*'|\S+)\s*$/i
|
|
278
|
+
const INERT_VERBS = /^\s*start-sleep\b/i
|
|
279
|
+
|
|
280
|
+
/** Flatten subshell parens to `;` segment separators, but ONLY outside `{}`
|
|
281
|
+
* script blocks and quotes. `(deploy && grep)` must split (a diagnostic inside
|
|
282
|
+
* parens must not blanket-immunize a non-diagnostic verb), but method-call
|
|
283
|
+
* parens inside a PowerShell script block (`ForEach-Object { $_.trim() }`) are
|
|
284
|
+
* part of that segment and must NOT split it. */
|
|
285
|
+
function flattenSubshellParens(command: string): string {
|
|
286
|
+
let result = ""
|
|
287
|
+
let quote: string | null = null
|
|
288
|
+
let braceDepth = 0
|
|
289
|
+
for (let i = 0; i < command.length; i++) {
|
|
290
|
+
const ch = command.charAt(i)
|
|
291
|
+
if (quote !== null) {
|
|
292
|
+
result += ch
|
|
293
|
+
if (ch === quote) quote = null
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
if (ch === '"' || ch === "'") {
|
|
297
|
+
quote = ch
|
|
298
|
+
result += ch
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
if (ch === "{") {
|
|
302
|
+
braceDepth += 1
|
|
303
|
+
result += ch
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if (ch === "}") {
|
|
307
|
+
braceDepth = Math.max(0, braceDepth - 1)
|
|
308
|
+
result += ch
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
if ((ch === "(" || ch === ")") && braceDepth === 0) {
|
|
312
|
+
result += ";"
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
result += ch
|
|
316
|
+
}
|
|
317
|
+
return result
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Like splitChain but tags each segment with whether it immediately follows a
|
|
321
|
+
* pipe (`|`). Formatter transparency is position-dependent (pipe tail only), so
|
|
322
|
+
* the immunity check needs this. `||` is a sequence (OR) separator, not a pipe:
|
|
323
|
+
* the segment after it is a producer, NOT a pipe tail. */
|
|
324
|
+
function splitChainTagged(command: string): Array<{ text: string; pipeTail: boolean }> {
|
|
325
|
+
const segments: Array<{ text: string; pipeTail: boolean }> = []
|
|
326
|
+
let current = ""
|
|
327
|
+
let quote: string | null = null
|
|
328
|
+
let depth = 0
|
|
329
|
+
let pipeTail = false
|
|
330
|
+
const flush = (): void => {
|
|
331
|
+
const trimmed = current.trim()
|
|
332
|
+
if (trimmed !== "") segments.push({ text: trimmed, pipeTail })
|
|
333
|
+
current = ""
|
|
334
|
+
}
|
|
335
|
+
let i = 0
|
|
336
|
+
while (i < command.length) {
|
|
337
|
+
const ch = command.charAt(i)
|
|
338
|
+
const next = command.charAt(i + 1)
|
|
339
|
+
if (quote !== null) {
|
|
340
|
+
current += ch
|
|
341
|
+
if (ch === quote) quote = null
|
|
342
|
+
i += 1
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
if (ch === '"' || ch === "'") {
|
|
346
|
+
quote = ch
|
|
347
|
+
current += ch
|
|
348
|
+
i += 1
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
if (ch === "(") {
|
|
352
|
+
depth += 1
|
|
353
|
+
current += ch
|
|
354
|
+
i += 1
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
if (ch === ")") {
|
|
358
|
+
depth = Math.max(0, depth - 1)
|
|
359
|
+
current += ch
|
|
360
|
+
i += 1
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
if (depth === 0) {
|
|
364
|
+
if (ch === ";" || ch === "\n" || ch === "\r") {
|
|
365
|
+
flush()
|
|
366
|
+
pipeTail = false
|
|
367
|
+
i += 1
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
if (ch === "&" && next === "&") {
|
|
371
|
+
flush()
|
|
372
|
+
pipeTail = false
|
|
373
|
+
i += 2
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
if (ch === "|") {
|
|
377
|
+
flush()
|
|
378
|
+
if (next === "|") {
|
|
379
|
+
// `||` is a sequence (OR) separator — next segment is a producer.
|
|
380
|
+
pipeTail = false
|
|
381
|
+
i += 2
|
|
382
|
+
} else if (next === "&") {
|
|
383
|
+
// `|&` is bash's pipe-stdout-and-stderr — still a pipe, next is a tail.
|
|
384
|
+
pipeTail = true
|
|
385
|
+
i += 2
|
|
386
|
+
} else {
|
|
387
|
+
pipeTail = true
|
|
388
|
+
i += 1
|
|
389
|
+
}
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
current += ch
|
|
394
|
+
i += 1
|
|
395
|
+
}
|
|
396
|
+
flush()
|
|
397
|
+
return segments
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* OpenCode normalizes non-zero exits to 1 in metadata, so discriminate by
|
|
402
|
+
* command shape. Exit-1 immunity requires EVERY producer segment to be
|
|
403
|
+
* diagnostic: in `deploy --broken && grep done log.txt` the exit is deploy's
|
|
404
|
+
* failure — granting immunity because grep appears later would hide it.
|
|
405
|
+
* Two segment kinds are transparent because they cannot be the failing
|
|
406
|
+
* producer: navigation (`cd`) — but ONLY when the segment is pure navigation,
|
|
407
|
+
* so `cd <path> npx vitest run` (no separator) keeps the diagnostic instead of
|
|
408
|
+
* being dropped wholesale — and pipe formatters, but the latter ONLY as a pipe
|
|
409
|
+
* tail. A formatter standing alone or as the terminal producer of a sequence
|
|
410
|
+
* (`npm test && tail -5 missing.log`) IS the producer, so its failure still
|
|
411
|
+
* counts. A real non-diagnostic command still breaks immunity — `npm install |
|
|
412
|
+
* select-object` still counts (npm install is not a diagnostic).
|
|
413
|
+
* Subshell paren groups are flattened to segment separators (`;`), NOT spaces,
|
|
414
|
+
* and only OUTSIDE `{}` script blocks: `(deploy && grep)` splits so a
|
|
415
|
+
* diagnostic nested in parens can't blanket-immunize a non-diagnostic verb,
|
|
416
|
+
* while method-call parens inside a script block (`ForEach-Object { $_.trim()
|
|
417
|
+
* }`) stay part of their segment and don't split it.
|
|
418
|
+
*/
|
|
166
419
|
export function isIntendedNonzero(command: string, exitCode: number): boolean {
|
|
167
|
-
|
|
420
|
+
if (exitCode !== 1) return false
|
|
421
|
+
let sawProducer = false
|
|
422
|
+
for (const { text, pipeTail } of splitChainTagged(flattenSubshellParens(command))) {
|
|
423
|
+
// Navigation is transparent only when it is PURE navigation. A segment that
|
|
424
|
+
// pairs a navigation verb with a diagnostic and no separator between them
|
|
425
|
+
// (`cd <path> npx vitest run ...`) must keep that diagnostic — dropping the
|
|
426
|
+
// whole segment as navigation would hide the command and break immunity.
|
|
427
|
+
if (NAVIGATION_VERBS.test(text) && !isDiagnosticText(text)) continue
|
|
428
|
+
// Session prep that cannot be the failing producer: a pure `$env:X=...` /
|
|
429
|
+
// `FOO=bar` assignment, and start-sleep.
|
|
430
|
+
if (ENV_ASSIGNMENT_SEGMENT.test(text)) continue
|
|
431
|
+
if (INERT_VERBS.test(text)) continue
|
|
432
|
+
if (pipeTail && PIPE_FORMATTERS.test(text)) continue
|
|
433
|
+
if (!isDiagnosticText(text)) return false
|
|
434
|
+
sawProducer = true
|
|
435
|
+
}
|
|
436
|
+
return sawProducer
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Count of non-transparent producer segments in a command chain — the segments
|
|
441
|
+
* that could plausibly be the failing producer (everything except pure
|
|
442
|
+
* navigation, pure env assignments, inert verbs, and pipe-tail formatters).
|
|
443
|
+
* Chain attribution is only defensible when exactly ONE such producer exists;
|
|
444
|
+
* with several, the exit code does not say which one failed, so attributing
|
|
445
|
+
* the failure to any single known segment fabricates evidence (a diagnostic
|
|
446
|
+
* segment's gate inflated by a non-diagnostic producer's failure).
|
|
447
|
+
*/
|
|
448
|
+
export function nonTransparentProducers(command: string): number {
|
|
449
|
+
let count = 0
|
|
450
|
+
for (const { text, pipeTail } of splitChainTagged(flattenSubshellParens(command))) {
|
|
451
|
+
if (NAVIGATION_VERBS.test(text) && !isDiagnosticText(text)) continue
|
|
452
|
+
if (ENV_ASSIGNMENT_SEGMENT.test(text)) continue
|
|
453
|
+
if (INERT_VERBS.test(text)) continue
|
|
454
|
+
if (pipeTail && PIPE_FORMATTERS.test(text)) continue
|
|
455
|
+
count += 1
|
|
456
|
+
}
|
|
457
|
+
return count
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// --- Residual identity (over-generic shape guard) ----------------------------
|
|
461
|
+
|
|
462
|
+
/** Placeholder tokens carry no identity — except `<code:...>`: the
|
|
463
|
+
* fingerprint IS the identity of a one-liner payload. */
|
|
464
|
+
const PLACEHOLDER_TOKEN = /^<(?:str|path|n|hash|uuid|sha|md5|ip|url|email|date)>$/
|
|
465
|
+
|
|
466
|
+
/** Shell plumbing: redirections and here-string/call-operator debris. */
|
|
467
|
+
const OPERATOR_TOKENS = new Set(["&", "@", ">", "<", ">&", ">>", "2>&1", "2>"])
|
|
468
|
+
|
|
469
|
+
/** Tokens that pass code/module to an interpreter — structure, not identity.
|
|
470
|
+
* `-m`/`--module` included: the NEXT token is the program, exactly like -c —
|
|
471
|
+
* `python -m <str>` (quoted module) must not gain identity from the flag. */
|
|
472
|
+
const CODE_PASSING_FLAGS = new Set(["-c", "-e", "--eval", "-command", "-encodedcommand", "-m", "--module"])
|
|
473
|
+
|
|
474
|
+
/** Bare flag tokens (`-x`, `--foo`) are switches, not call identity — after a
|
|
475
|
+
* wrapper head, a flag-only remainder matches an entire command family
|
|
476
|
+
* (`cmd <path> <str> -f`), which must not enforce. */
|
|
477
|
+
const FLAG_TOKEN = /^--?[a-z]/i
|
|
478
|
+
|
|
479
|
+
/** Shell builtins that only position the session: a chain headed by one
|
|
480
|
+
* (`cd <path> && python <path>`) must not borrow identity from the builtin —
|
|
481
|
+
* the whole chain may be parameterized away. */
|
|
482
|
+
const NO_IDENTITY_HEADS = new Set(["cd", "pushd", "popd", "set-location", "exit"])
|
|
483
|
+
|
|
484
|
+
/** Pipe-stage cmdlets that only post-process output: a segment made of
|
|
485
|
+
* plumbing (`... | select-object -last <n>`) contributes no identity. */
|
|
486
|
+
const PLUMBING_HEADS = new Set([
|
|
487
|
+
"select-object",
|
|
488
|
+
"select-string",
|
|
489
|
+
"out-string",
|
|
490
|
+
"out-file",
|
|
491
|
+
"out-null",
|
|
492
|
+
"foreach-object",
|
|
493
|
+
"where-object",
|
|
494
|
+
"sort-object",
|
|
495
|
+
"measure-object",
|
|
496
|
+
"tee-object",
|
|
497
|
+
"write-host",
|
|
498
|
+
"write-output",
|
|
499
|
+
"more",
|
|
500
|
+
])
|
|
501
|
+
|
|
502
|
+
/** Wrappers whose bare name is not a call identity: their ARGUMENTS are the
|
|
503
|
+
* call. If the arguments were all parameterized away, the signature matches
|
|
504
|
+
* an entire command family — enforcing it would punish unrelated calls. */
|
|
505
|
+
const WRAPPER_BASENAMES = new Set(["cmd", "py", "node", "python", "python3", "bun", "deno", "perl", "ruby", "pwsh", "powershell"])
|
|
506
|
+
|
|
507
|
+
function baseName(token: string): string {
|
|
508
|
+
const bare = token.replace(/^["']+|["']+$/g, "")
|
|
509
|
+
const parts = bare.split(/[\\/]/)
|
|
510
|
+
const last = parts[parts.length - 1] ?? bare
|
|
511
|
+
return last.toLowerCase().replace(/\.exe$/, "")
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function segmentHasIdentity(segment: string): boolean {
|
|
515
|
+
const tokens = segment.split(/\s+/).filter((t) => t !== "")
|
|
516
|
+
let head = ""
|
|
517
|
+
let headIdx = -1
|
|
518
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
519
|
+
const token = tokens[i] ?? ""
|
|
520
|
+
if (PLACEHOLDER_TOKEN.test(token) || OPERATOR_TOKENS.has(token) || CODE_PASSING_FLAGS.has(token)) continue
|
|
521
|
+
head = token
|
|
522
|
+
headIdx = i
|
|
523
|
+
break
|
|
524
|
+
}
|
|
525
|
+
if (head === "") return false
|
|
526
|
+
if (head.startsWith("<code:")) return true
|
|
527
|
+
if (PLUMBING_HEADS.has(head)) return false
|
|
528
|
+
if (NO_IDENTITY_HEADS.has(head)) return false
|
|
529
|
+
if (!WRAPPER_BASENAMES.has(baseName(head))) return true
|
|
530
|
+
// Wrapper/interpreter head: identity must come from a surviving argument
|
|
531
|
+
// (a literal path/script, or a <code:...> fingerprint). Flags are switches,
|
|
532
|
+
// not identity — a flag-only remainder is an over-generic command family.
|
|
533
|
+
for (let i = headIdx + 1; i < tokens.length; i++) {
|
|
534
|
+
const token = tokens[i] ?? ""
|
|
535
|
+
if (token.startsWith("<code:")) return true
|
|
536
|
+
if (PLACEHOLDER_TOKEN.test(token) || CODE_PASSING_FLAGS.has(token) || OPERATOR_TOKENS.has(token) || FLAG_TOKEN.test(token)) continue
|
|
537
|
+
return true
|
|
538
|
+
}
|
|
539
|
+
return false
|
|
168
540
|
}
|
|
169
541
|
|
|
170
542
|
/**
|
|
171
|
-
* A
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
543
|
+
* A signature keeps residual identity when at least one chain segment names
|
|
544
|
+
* a concrete call. Signatures whose substance was entirely parameterized —
|
|
545
|
+
* `cmd <path> <str>`, `node <str> <n> >& <n>`, `& <str> -c @ <str> @`,
|
|
546
|
+
* chains starting with an unknown `<str>` head — match whole command
|
|
547
|
+
* families: they may be measured (watching) but never enforced. This
|
|
548
|
+
* generalizes the legacy bare-one-liner guard: ANY future normalization gap
|
|
549
|
+
* degrades to watching instead of blocking arbitrary calls.
|
|
175
550
|
*/
|
|
176
|
-
|
|
551
|
+
export function hasResidualIdentity(signature: string): boolean {
|
|
552
|
+
const body = signature.startsWith("bash:") ? signature.slice("bash:".length) : signature
|
|
553
|
+
return body.split(/\s*(?:\|\||&&|[|;&])\s*|\n+/).some((segment) => segmentHasIdentity(segment))
|
|
554
|
+
}
|
|
177
555
|
|
|
178
556
|
/**
|
|
179
557
|
* Blocking policy: only bash commands that are NOT diagnostics may ever
|
|
180
558
|
* become enforced gates. File probes and diagnostic queries are measured
|
|
181
559
|
* (watching) but never interrupt the agent — the data showed blocking them
|
|
182
|
-
* punishes normal work.
|
|
560
|
+
* punishes normal work. Signatures without residual identity never enforce
|
|
561
|
+
* at any tier — they are too broad to interrupt anything.
|
|
183
562
|
*/
|
|
184
563
|
export function canBlock(tool: string, signature: string): boolean {
|
|
185
564
|
if (tool !== "bash") return false
|
|
186
|
-
if (
|
|
187
|
-
return !
|
|
565
|
+
if (!hasResidualIdentity(signature)) return false
|
|
566
|
+
return !isDiagnosticSignature(signature)
|
|
188
567
|
}
|
|
189
568
|
|
|
190
569
|
/**
|
|
191
570
|
* Remind-only policy: diagnostic bash commands still surface a REMINDER when
|
|
192
571
|
* they recur (the old behavior gave them zero signal), but they NEVER block —
|
|
193
572
|
* blocking a test/lint the agent is iterating on punishes normal work.
|
|
194
|
-
* Generic one-liner shapes stay unenforced: they are too broad to remind on.
|
|
195
573
|
*/
|
|
196
574
|
export function canRemind(tool: string, signature: string): boolean {
|
|
197
575
|
if (tool !== "bash") return false
|
|
198
|
-
if (
|
|
576
|
+
if (!hasResidualIdentity(signature)) return false
|
|
199
577
|
return isDiagnosticSignature(signature)
|
|
200
578
|
}
|
|
201
579
|
|
|
@@ -277,7 +655,8 @@ export function splitChain(command: string): string[] {
|
|
|
277
655
|
}
|
|
278
656
|
if (ch === "|") {
|
|
279
657
|
flush()
|
|
280
|
-
|
|
658
|
+
// `||` (OR) and `|&` (bash pipe stdout+stderr) are 2-char; bare `|` is 1.
|
|
659
|
+
i += next === "|" || next === "&" ? 2 : 1
|
|
281
660
|
continue
|
|
282
661
|
}
|
|
283
662
|
}
|
|
@@ -288,10 +667,28 @@ export function splitChain(command: string): string[] {
|
|
|
288
667
|
return segments
|
|
289
668
|
}
|
|
290
669
|
|
|
291
|
-
/**
|
|
670
|
+
/**
|
|
671
|
+
* Per-segment signatures for a bash command (bypass protection for chains).
|
|
672
|
+
* cmd wrappers expand recursively: quote-aware splitChain keeps
|
|
673
|
+
* `cmd /c "a && gated"` as ONE segment, so the inner chain must unfold here —
|
|
674
|
+
* a gate on the inner command must fire through the wrapper. Depth-bounded:
|
|
675
|
+
* nested wrappers are pathological.
|
|
676
|
+
*/
|
|
292
677
|
export function bashSegmentSignatures(command: string): string[] {
|
|
293
678
|
const clean = command.replace(OVERRIDE_MARKER, "")
|
|
294
|
-
|
|
679
|
+
const signatures: string[] = []
|
|
680
|
+
const expand = (text: string, depth: number): void => {
|
|
681
|
+
for (const segment of splitChain(text)) {
|
|
682
|
+
const payload = depth < 3 ? cmdWrapperPayload(segment) : null
|
|
683
|
+
if (payload === null) {
|
|
684
|
+
signatures.push(`bash:${normalizeCommand(segment)}`)
|
|
685
|
+
} else {
|
|
686
|
+
expand(payload, depth + 1)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
expand(clean, 0)
|
|
691
|
+
return signatures
|
|
295
692
|
}
|
|
296
693
|
|
|
297
694
|
/** Normalize a file path: keep basename + extension, drop directories. */
|
|
@@ -373,12 +770,25 @@ const CODE_FINGERPRINTS = /<code:[0-9a-f]+>/g
|
|
|
373
770
|
* DISJOINT flag sets are different operations and must never fuzzy-merge
|
|
374
771
|
* ("train --lr <n>" vs "train --epochs <n>"). A subset IS allowed — extra
|
|
375
772
|
* switches on the same operation ("gradlew test --no-daemon") still belong to
|
|
376
|
-
* the same gate, otherwise enforcement fragments across harmless variants.
|
|
773
|
+
* the same gate, otherwise enforcement fragments across harmless variants.
|
|
774
|
+
* Cached: the flood path calls fuzzySimilar per gate under the gates lock and
|
|
775
|
+
* would otherwise re-split/sort the SAME incoming signature on every pair. */
|
|
776
|
+
const FLAG_TOKEN_CACHE_CAP = 512
|
|
777
|
+
const flagTokenCache = new Map<string, string[]>()
|
|
377
778
|
function flagTokens(signature: string): string[] {
|
|
378
|
-
|
|
779
|
+
const cached = flagTokenCache.get(signature)
|
|
780
|
+
if (cached !== undefined) return cached
|
|
781
|
+
const tokens = signature
|
|
379
782
|
.split(/\s+/)
|
|
380
783
|
.filter((token) => token.startsWith("-"))
|
|
381
784
|
.sort()
|
|
785
|
+
if (flagTokenCache.size >= FLAG_TOKEN_CACHE_CAP) {
|
|
786
|
+
// Evict an arbitrary (oldest-inserted) entry to bound memory.
|
|
787
|
+
const oldest = flagTokenCache.keys().next().value
|
|
788
|
+
if (oldest !== undefined) flagTokenCache.delete(oldest)
|
|
789
|
+
}
|
|
790
|
+
flagTokenCache.set(signature, tokens)
|
|
791
|
+
return tokens
|
|
382
792
|
}
|
|
383
793
|
|
|
384
794
|
function flagSubset(a: string[], b: string[]): boolean {
|
|
@@ -404,6 +814,16 @@ export const FUZZY_MAX_LEN = 300
|
|
|
404
814
|
*/
|
|
405
815
|
export function fuzzySimilar(a: string, b: string): boolean {
|
|
406
816
|
if (a === b) return true
|
|
817
|
+
// Cheapest rejects FIRST: the length band is O(1) with zero allocation and
|
|
818
|
+
// zero false negatives — it must run before any regex/flag work, because
|
|
819
|
+
// the flood path calls this per gate under the gates lock.
|
|
820
|
+
const maxLen = Math.max(a.length, b.length)
|
|
821
|
+
if (maxLen === 0) return true
|
|
822
|
+
if (maxLen > FUZZY_MAX_LEN) return false
|
|
823
|
+
// Triangle inequality: distance >= |lenA - lenB|. If even that floor
|
|
824
|
+
// exceeds the ratio threshold, no Levenshtein result can pass — an O(1)
|
|
825
|
+
// pre-filter with zero false negatives that skips most DP computations.
|
|
826
|
+
if (Math.abs(a.length - b.length) / maxLen > 0.3) return false
|
|
407
827
|
const codesA = a.match(CODE_FINGERPRINTS)
|
|
408
828
|
const codesB = b.match(CODE_FINGERPRINTS)
|
|
409
829
|
if (codesA !== null || codesB !== null) {
|
|
@@ -412,13 +832,6 @@ export function fuzzySimilar(a: string, b: string): boolean {
|
|
|
412
832
|
const flagsA = flagTokens(a)
|
|
413
833
|
const flagsB = flagTokens(b)
|
|
414
834
|
if (!flagSubset(flagsA, flagsB) && !flagSubset(flagsB, flagsA)) return false
|
|
415
|
-
const maxLen = Math.max(a.length, b.length)
|
|
416
|
-
if (maxLen === 0) return true
|
|
417
|
-
if (maxLen > FUZZY_MAX_LEN) return false
|
|
418
|
-
// Triangle inequality: distance >= |lenA - lenB|. If even that floor
|
|
419
|
-
// exceeds the ratio threshold, no Levenshtein result can pass — an O(1)
|
|
420
|
-
// pre-filter with zero false negatives that skips most DP computations.
|
|
421
|
-
if (Math.abs(a.length - b.length) / maxLen > 0.3) return false
|
|
422
835
|
const distance = levenshtein(a, b)
|
|
423
836
|
return distance >= 3 && distance / maxLen <= 0.3
|
|
424
837
|
}
|
|
@@ -437,20 +850,97 @@ export interface FailureDetection {
|
|
|
437
850
|
* event channel instead.
|
|
438
851
|
*/
|
|
439
852
|
const FAILURE_SIGNATURES: RegExp[] = [
|
|
440
|
-
/exit code:?\s*[1-9]\d*/i,
|
|
853
|
+
/exit (?:code|status):?\s*[1-9]\d*/i,
|
|
441
854
|
/\berror TS\d+\b/,
|
|
442
855
|
/\bENOENT\b|\bEACCES\b|\bEPERM\b/,
|
|
443
856
|
/command not found/i,
|
|
444
|
-
|
|
857
|
+
// cmd AND PowerShell wordings of "unknown command" — pwsh phrasing was
|
|
858
|
+
// uncovered, so head/tail/wc gates stored the "Check the spelling" boilerplate
|
|
859
|
+
// tail instead of the cause line.
|
|
860
|
+
/is not recognized as (?:an internal or external command|the name of a cmdlet)/i,
|
|
445
861
|
/\b(SyntaxError|TypeError|ReferenceError|AssertionError)\b/,
|
|
446
862
|
/Tests:\s+\d+\s+failed/i,
|
|
447
|
-
|
|
863
|
+
// Runner summaries, language-agnostic. Count-bearing forms require a NON-ZERO
|
|
864
|
+
// count in EITHER order ("1 failed" / "Failed: 1" / "Failures: 1") so a pass
|
|
865
|
+
// tally ("0 failed", "Failed: 0") never reads as a failure. Covers pytest/
|
|
866
|
+
// playwright/vitest/jest ("N failed"), RSpec/Elixir/minitest ("N failure(s)"),
|
|
867
|
+
// dotnet/Maven/sbt/unittest ("Failed: N", "Failures: N", "failures=N").
|
|
868
|
+
/\b[1-9]\d*\s+fail(?:ed|ures?)\b/i,
|
|
869
|
+
/\bfail(?:ed|ures?)\s*[:=]\s*[1-9]\d*\b/i,
|
|
870
|
+
/no tests? (?:found|matched|run|were executed)/i,
|
|
871
|
+
// Generic error prefix (Playwright "Error: No tests found", Node, tracebacks).
|
|
872
|
+
/^error:/i,
|
|
873
|
+
// Compiler/tool error prefixes that carry a bracket before the colon:
|
|
874
|
+
// Rust "error[E0308]:" and Maven/SBT "[ERROR] ...".
|
|
875
|
+
/^\s*error\s*\[/i,
|
|
876
|
+
/^\s*\[ERROR\]/i,
|
|
877
|
+
// Go: "--- FAIL: TestName", "FAIL\tpkg", standalone "FAIL" (uppercase; pass is
|
|
878
|
+
// "ok\tpkg"). \b keeps it off "FAILED"/"FAILURE".
|
|
879
|
+
/\bFAIL\b/,
|
|
880
|
+
// Build-level status words that never appear in a pass summary: Maven
|
|
881
|
+
// "BUILD FAILURE", Gradle "BUILD FAILED" / "FAILURE: Build failed", sbt
|
|
882
|
+
// "*** 1 TEST FAILED ***", dotnet build "Build FAILED.".
|
|
883
|
+
/\bBUILD\s+(?:FAILURE|FAILED)\b/i,
|
|
884
|
+
/\bFAILURE\b/i,
|
|
885
|
+
/\bTESTS?\s+FAILED\b/i,
|
|
886
|
+
// TAP ("node --test") failure marker.
|
|
887
|
+
/^not ok\b/i,
|
|
448
888
|
/thread '[^']*' panicked/,
|
|
889
|
+
/\bpanic:/i,
|
|
449
890
|
/\bFATAL\b/,
|
|
450
891
|
]
|
|
451
892
|
|
|
893
|
+
/** Lines that read like a SUCCESS summary. Quoting one as a failure's "last
|
|
894
|
+
* error" teaches the agent to fix something that worked — the store held
|
|
895
|
+
* "17 passed (3.1m)" as evidence for a failing gate (MidasAI). Matched as a
|
|
896
|
+
* SUBSTRING so decorated summaries ("==== 10 passed ====", "ok\tpkg 0.3s",
|
|
897
|
+
* "BUILD SUCCESSFUL") are caught too — the pass shape need not start the line. */
|
|
898
|
+
const SUCCESS_SHAPED: RegExp[] = [
|
|
899
|
+
/\b\d+\s+passed\b/i,
|
|
900
|
+
/\b\d+\s+(?:tests?|specs?|examples?)\s+passed\b/i,
|
|
901
|
+
/\ball tests passed\b/i,
|
|
902
|
+
/\bBUILD SUCCESS(?:FUL)?\b/i,
|
|
903
|
+
/\btest result: ok\b/i,
|
|
904
|
+
/\bOK\s*\(\s*\d+\s+tests?/i,
|
|
905
|
+
/^ok\s+\S/i,
|
|
906
|
+
// dotnet: pass summaries lead with "Passed!" ("Failed!" = failure) or say
|
|
907
|
+
// "Build succeeded." / "Test Run Successful."
|
|
908
|
+
/^Passed!/i,
|
|
909
|
+
/\bBuild succeeded\b/i,
|
|
910
|
+
/\bTest Run Successful\b/i,
|
|
911
|
+
/\bno offenses detected\b/i,
|
|
912
|
+
/\b0 issues\b/i,
|
|
913
|
+
// gradle/node noise that is never failure evidence: task summary, config-cache
|
|
914
|
+
// note, node version banner (printed at the tail of a crash, it is not the cause).
|
|
915
|
+
/\b\d+\s+actionable tasks?\b/i,
|
|
916
|
+
/\bConfiguration cache entry\b/i,
|
|
917
|
+
/^Node\.js v\d+\./i,
|
|
918
|
+
]
|
|
919
|
+
|
|
920
|
+
export function looksLikeSuccess(line: string): boolean {
|
|
921
|
+
// A line that reports failures is not a success even when it also tallies
|
|
922
|
+
// passes ("1 failed, 1780 passed", "Failed: 1"). Non-zero count in either
|
|
923
|
+
// order, mirroring the failure signatures.
|
|
924
|
+
if (/\b[1-9]\d*\s+fail(?:ed|ures?)\b/i.test(line)) return false
|
|
925
|
+
if (/\bfail(?:ed|ures?)\s*[:=]\s*[1-9]\d*\b/i.test(line)) return false
|
|
926
|
+
return SUCCESS_SHAPED.some((rule) => rule.test(line))
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Leading decorations runners wrap summaries in ("====", "---", "[info]",
|
|
930
|
+
* "✔") — stripped before matching so a pattern needs not anticipate every
|
|
931
|
+
* tool's framing. Bounded so a real line is never eaten whole. */
|
|
932
|
+
const LEADING_DECORATION = /^[\s=\-─—_*#•»>]{0,40}/
|
|
933
|
+
|
|
934
|
+
export function looksLikeFailure(line: string): boolean {
|
|
935
|
+
if (line.trim() === "" || looksLikeSuccess(line)) return false
|
|
936
|
+
const bare = line.replace(LEADING_DECORATION, "")
|
|
937
|
+
return FAILURE_SIGNATURES.some((rule) => rule.test(line) || rule.test(bare))
|
|
938
|
+
}
|
|
939
|
+
|
|
452
940
|
export function detectFailure(outputText: string): FailureDetection {
|
|
453
|
-
|
|
941
|
+
// PowerShell colors errors with VT sequences — strip before scanning, or
|
|
942
|
+
// the escapes persist into snippets/corrections shown to the agent.
|
|
943
|
+
for (const line of stripControl(outputText).split("\n")) {
|
|
454
944
|
for (const signature of FAILURE_SIGNATURES) {
|
|
455
945
|
if (signature.test(line)) {
|
|
456
946
|
return { matched: true, snippet: line.trim().slice(0, 200) }
|
|
@@ -463,14 +953,29 @@ export function detectFailure(outputText: string): FailureDetection {
|
|
|
463
953
|
/**
|
|
464
954
|
* For exit-code failures whose output matched no signature, a bare
|
|
465
955
|
* "exit code N" gives a human/agent nothing to write a correction from.
|
|
466
|
-
* Bash output is command output (safe to surface)
|
|
467
|
-
* line — compilers/test runners print their summary
|
|
956
|
+
* Bash output is command output (safe to surface). Scan from the END for a
|
|
957
|
+
* failure-shaped line — compilers/test runners print their summary last, but a
|
|
958
|
+
* SUCCESS-shaped tail ("17 passed") is never failure evidence: chained commands
|
|
959
|
+
* and `Select-Object -Last N` pipelines put another shard's pass summary there.
|
|
960
|
+
* Prefer the last real error line, then the last non-success line, then the exit code.
|
|
468
961
|
*/
|
|
469
962
|
export function failureSnippet(outputText: string, exitCode: number | null): string {
|
|
470
|
-
const lines = outputText
|
|
963
|
+
const lines = stripControl(outputText)
|
|
471
964
|
.split("\n")
|
|
472
965
|
.map((l) => l.trim())
|
|
473
966
|
.filter((l) => l !== "")
|
|
967
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
968
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
969
|
+
const line = lines[i] ?? ""
|
|
970
|
+
if (looksLikeFailure(line)) return line.slice(0, 200)
|
|
971
|
+
}
|
|
972
|
+
// No failure-shaped line: the last non-success line beats a bare exit code.
|
|
973
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
974
|
+
const line = lines[i] ?? ""
|
|
975
|
+
if (!looksLikeSuccess(line)) return line.slice(0, 200)
|
|
976
|
+
}
|
|
977
|
+
return `exit code ${exitCode}`
|
|
978
|
+
}
|
|
474
979
|
const tail = lines[lines.length - 1]
|
|
475
980
|
if (tail !== undefined && tail !== "") return tail.slice(0, 200)
|
|
476
981
|
return `exit code ${exitCode}`
|
|
@@ -482,6 +987,9 @@ export function failureSnippet(outputText: string, exitCode: number | null): str
|
|
|
482
987
|
* Infrastructure noise, not agent mistakes: aborted/cancelled executions
|
|
483
988
|
* (user hit stop, background task reaped) teach nothing and fragmented the
|
|
484
989
|
* store with unactionable patterns. Aborted != failed.
|
|
990
|
+
* Boundary: SERVER-side unavailability (daemon down, transport errors, 5xx)
|
|
991
|
+
* is noise — the agent cannot learn from "the service was down". CLIENT-side
|
|
992
|
+
* mistakes (4xx, wrong path, syntax) stay teachable and are NOT matched here.
|
|
485
993
|
*/
|
|
486
994
|
const NOISE_ERRORS: RegExp[] = [
|
|
487
995
|
/tool execution aborted/i,
|
|
@@ -489,12 +997,132 @@ const NOISE_ERRORS: RegExp[] = [
|
|
|
489
997
|
/\baborted by user\b/i,
|
|
490
998
|
/\bcancelled by user\b/i,
|
|
491
999
|
/\bcanceled by user\b/i,
|
|
1000
|
+
/no results found for your query/i, // grep_app empty search: the tool worked, nothing matched
|
|
1001
|
+
/user dismissed this question/i, // question tool: the user's choice, not a failure
|
|
1002
|
+
// Infrastructure unavailability: the service, not the command, failed.
|
|
1003
|
+
/lsp (?:server|daemon|process)[^.\n]*(?:unreachable|not running|disconnected|crashed|did not become reachable)/i,
|
|
1004
|
+
/\b(?:daemon|server)\b[^.\n]*(?:unreachable|did not become reachable)/i,
|
|
1005
|
+
/streamable ?http ?error/i, // MCP streamable-http transport failure
|
|
1006
|
+
/\bmcp error\b/i, // MCP transport/protocol errors
|
|
1007
|
+
/non.?2xx status code/i, // webfetch HTTP failure: the endpoint answered, the URL fetch didn't
|
|
1008
|
+
/\btransport error\b/i, // webfetch/gRPC transport failure: the connection itself never completed
|
|
1009
|
+
// Browser automation: the page/context/browser was already closed when the
|
|
1010
|
+
// action ran — a transient startup/state hiccup fixed by relaunching, not an
|
|
1011
|
+
// agent habit (and a non-bash tool error could only ever watch anyway).
|
|
1012
|
+
/target page, context or browser has been closed/i,
|
|
1013
|
+
// LSP too slow to answer a diagnostics request in the window — a latency
|
|
1014
|
+
// hiccup, not an agent mistake (and lsp_* tools only ever watch anyway).
|
|
1015
|
+
/timed out waiting for (?:fresh )?diagnostics/i,
|
|
492
1016
|
]
|
|
493
1017
|
|
|
494
1018
|
export function isNoiseError(errorText: string): boolean {
|
|
495
1019
|
return NOISE_ERRORS.some((rule) => rule.test(errorText))
|
|
496
1020
|
}
|
|
497
1021
|
|
|
1022
|
+
// --- Long-running command guard ------------------------------------------------
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* High-confidence dev-server / watcher starters. Running one in FOREGROUND bash
|
|
1026
|
+
* blocks the tool call until its timeout (~2 min) and strands an orphan process.
|
|
1027
|
+
* Deliberately conservative: only unambiguous starters are listed (ambiguous
|
|
1028
|
+
* `node <file>`, `go run`, `dotnet run` are NOT here — they may be one-shots).
|
|
1029
|
+
* This is a bounded, recognizable class, unlike open-ended error detection.
|
|
1030
|
+
*/
|
|
1031
|
+
const SERVER_STARTERS: RegExp[] = [
|
|
1032
|
+
// `start` included: `npm start` is the canonical dev-server script (CRA et al).
|
|
1033
|
+
// `workspace <name>` covers monorepo `yarn workspace app dev`.
|
|
1034
|
+
/\b(npm|yarn|pnpm|bun)\s+(run\s+|workspace\s+\S+\s+)?(dev|serve|watch|start)\b/i,
|
|
1035
|
+
/\b(next|nuxt|astro)\s+dev\b/i,
|
|
1036
|
+
/\bng\s+serve\b/i, // Angular
|
|
1037
|
+
// `vite` as a command: not in a filename ("vite.config.ts"), not "vitest", not a
|
|
1038
|
+
// `build:` script target ("npm run build:vite"), and not followed by a bare
|
|
1039
|
+
// `build` (one-shot). `vite build --watch` is a watcher (separate rule).
|
|
1040
|
+
/(?<![:\w])vite\b(?![.\w])(?![^\n]*\bbuild\b)/i,
|
|
1041
|
+
/\bvite\s+build\b[^\n]*\bwatch\b/i,
|
|
1042
|
+
// Flask 2.3+ puts `--app X` between the binary and `run`.
|
|
1043
|
+
/\b(flask|streamlit)\b[^\n|;&]*\brun\b/i,
|
|
1044
|
+
// Require an arg (module:var or flag) so `pip install uvicorn gunicorn` and
|
|
1045
|
+
// `grep uvicorn` (mention/install) don't read as starting a server.
|
|
1046
|
+
/\b(uvicorn|gunicorn)\s+(?:--?\w[^\s]*|\S+:\S+)/i,
|
|
1047
|
+
/\bpython\d?(?:\.\d+)?\s+-m\s+http\.server\b/i,
|
|
1048
|
+
/\b(python\d?(?:\.\d+)?\s+)?manage\.py\s+runserver\b/i, // Django
|
|
1049
|
+
/\bdjango-admin\s+runserver\b/i,
|
|
1050
|
+
// Python scripts named like servers (Flask/FastAPI entrypoints).
|
|
1051
|
+
// Negative lookahead for `\s+cli\b`: `python …/server.py cli …` is a one-shot
|
|
1052
|
+
// CLI invocation (e.g. muffin-supervisor), not a foreground server start.
|
|
1053
|
+
/\bpython\d?(?:\.\d+)?\s+(?:\S*[\/\\])?(?:app|server|main|run|wsgi|asgi)\.py\b(?!\s+cli\b)/i,
|
|
1054
|
+
/\bphp\s+(-S|artisan\s+serve)\b/i, // built-in / Laravel
|
|
1055
|
+
/\bjupyter\s+(lab|notebook)\b/i,
|
|
1056
|
+
/\b(webpack-dev-server|webpack\s+serve)\b/i,
|
|
1057
|
+
/\b(http-server|live-server)\b/i,
|
|
1058
|
+
// Docker foreground services: `compose up` / `run` WITHOUT -d/--detach block.
|
|
1059
|
+
// `docker run` constrained to server-ish flags (-p/--publish/-it) to avoid
|
|
1060
|
+
// flagging one-shot containers (`docker run --rm alpine echo hi`).
|
|
1061
|
+
/\bdocker(?:-compose)?\s+compose\s+up\b(?![^\n]*\s(?:-d|--detach)\b)/i,
|
|
1062
|
+
/\bdocker\s+run\b(?![^\n]*\s(?:-d|--detach)\b)(?=[^\n]*\s(?:-p|--publish|-it)\b)/i,
|
|
1063
|
+
// Built-in runtime watchers (Node 18+ / Bun): unambiguous long-running.
|
|
1064
|
+
/\bnode\s+--watch\b/i,
|
|
1065
|
+
/\bbun\s+--watch\b/i,
|
|
1066
|
+
/\bmvn\b[^\n]*\bspring-boot:run\b/i,
|
|
1067
|
+
/\bgradlew?\b[^\n]*\bbootRun\b/i,
|
|
1068
|
+
/\bdotnet\s+watch\b/i, // `dotnet run` stays excluded (ambiguous one-shot vs server)
|
|
1069
|
+
/\brails\s+(s|server)\b/i,
|
|
1070
|
+
/\bhugo\s+server\b/i,
|
|
1071
|
+
/\bjekyll\s+serve\b/i,
|
|
1072
|
+
/\bmkdocs\s+serve\b/i,
|
|
1073
|
+
/\bmix\s+phx\.server\b/i, // Elixir/Phoenix
|
|
1074
|
+
/\biex\s+-S\s+mix\b/i,
|
|
1075
|
+
/\bnodemon\b/i,
|
|
1076
|
+
/\b(expo|react-native)\s+start\b/i,
|
|
1077
|
+
/\bollama\s+serve\b/i,
|
|
1078
|
+
]
|
|
1079
|
+
|
|
1080
|
+
/** Markers that mean the process is already detached / backgrounded. */
|
|
1081
|
+
function isDetached(command: string): boolean {
|
|
1082
|
+
// Start-Process detaches UNLESS -Wait (blocks for exit) or -NoNewWindow
|
|
1083
|
+
// (runs in the caller's window, effectively foreground).
|
|
1084
|
+
if (/\bStart-Process\b/i.test(command)) return !/\s-(?:Wait|NoNewWindow)\b/i.test(command)
|
|
1085
|
+
// Self-detaching managers/sessions.
|
|
1086
|
+
if (/\b(Start-Job|pm2|forever|daemonize|systemd-run|setsid)\b/i.test(command)) return true
|
|
1087
|
+
if (/\btmux\s+(new-session|new)\b/i.test(command)) return true
|
|
1088
|
+
// `screen -dmS`/`-d -m` start detached; a bare `screen -S name` is foreground.
|
|
1089
|
+
if (/\bscreen\s+-(d|m)/i.test(command)) return true
|
|
1090
|
+
if (/\bstart\s+\/b\b/i.test(command)) return true // cmd.exe background
|
|
1091
|
+
// A standalone background `&` (not part of `&&`), anywhere — trailing,
|
|
1092
|
+
// mid-chain, or closing a subshell (`(cmd &)`). `&&` chains stay foreground.
|
|
1093
|
+
// BUT `& … wait` blocks until the background job finishes, and `nohup X`
|
|
1094
|
+
// without `&` still runs in the foreground — both are NOT detached.
|
|
1095
|
+
if (/(^|[^&])&([^&]|$)/.test(command.trim())) return !/\bwait\b/i.test(command)
|
|
1096
|
+
if (/\b(nohup|disown)\b/i.test(command)) return false // needs `&` to detach; handled above
|
|
1097
|
+
return false
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export function isLongRunningCommand(command: string): boolean {
|
|
1101
|
+
return SERVER_STARTERS.some((rule) => rule.test(command))
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** Warn only for a foreground server start; a detached one is fine. */
|
|
1105
|
+
export function shouldWarnLongRunning(command: string): boolean {
|
|
1106
|
+
return isLongRunningCommand(command) && !isDetached(command)
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Agent-written polling loops with no timeout guard. These are NOT servers —
|
|
1111
|
+
* they hang in a while/until/for loop waiting for a condition (usually a health
|
|
1112
|
+
* endpoint) that may never arrive, until the bash timeout kills them.
|
|
1113
|
+
*/
|
|
1114
|
+
const WAIT_LOOP: RegExp[] = [
|
|
1115
|
+
/\b(?:while|until)\b[^\n]*\b(?:sleep|Start-Sleep)\s+\d/i,
|
|
1116
|
+
// Multi-line loops: infinite condition ($true/true) or a network/health probe,
|
|
1117
|
+
// with a sleep anywhere in the body (possibly on later lines).
|
|
1118
|
+
/\b(?:while|until)\b[^\n]*(?:\$true|\btrue\b|\bTest-Connection\b|\bInvoke-WebRequest\b|\bInvoke-RestMethod\b|\bcurl\b|\bwget\b)[\s\S]{0,500}?\b(?:sleep|Start-Sleep)\b/i,
|
|
1119
|
+
/\bfor\s*\([^\n]*\)\s*\{[\s\S]{0,400}?\b(?:sleep|Start-Sleep)\s+\d/i,
|
|
1120
|
+
]
|
|
1121
|
+
|
|
1122
|
+
export function shouldWarnWaitLoop(command: string): boolean {
|
|
1123
|
+
return WAIT_LOOP.some((rule) => rule.test(command))
|
|
1124
|
+
}
|
|
1125
|
+
|
|
498
1126
|
// --- Default corrections ------------------------------------------------------
|
|
499
1127
|
|
|
500
1128
|
/**
|
|
@@ -519,7 +1147,23 @@ export function suggestCorrection(signature: string, snippet: string): string {
|
|
|
519
1147
|
if (/\b(npm|yarn|pnpm|bun)\s+(install|ci)\b/i.test(signature)) {
|
|
520
1148
|
return "Dependency install failed — inspect the resolver error; try the lockfile/legacy-peer-deps route the repo documents."
|
|
521
1149
|
}
|
|
522
|
-
|
|
1150
|
+
// Unix tools absent from PowerShell: a missing-command failure on one is a platform habit, teach the native form.
|
|
1151
|
+
if (/\b(head|tail|cat|wc|grep|sed|awk|cut|sort|uniq|tr|xargs|less)\b/i.test(signature) && /not recognized|command not found|Check the spelling of the name/i.test(snippet)) {
|
|
1152
|
+
return "Unix tool, not a PowerShell command — use the native equivalent: Select-Object -First/-Last for head/tail, Get-Content for cat, Select-String for grep, (Get-Content <file>).Count for wc -l."
|
|
1153
|
+
}
|
|
1154
|
+
// File-tool probes: not-found means a wrong path guess — locate the file
|
|
1155
|
+
// instead of retrying guessed path variants.
|
|
1156
|
+
if (/^(read|edit|write):/i.test(signature) && /ENOENT|no such file|not found/i.test(snippet)) {
|
|
1157
|
+
return "The file does not exist at that path — locate the real path with glob/grep before reading/editing; do not guess path variants."
|
|
1158
|
+
}
|
|
1159
|
+
// Missing command on this machine — install it or pick an available tool.
|
|
1160
|
+
if (/^bash:/i.test(signature) && /command not found|not recognized/i.test(snippet)) {
|
|
1161
|
+
return "The command is not installed on this machine — install it first, or use an alternative tool that is already available."
|
|
1162
|
+
}
|
|
1163
|
+
// A success-shaped snippet is never an error — quoting it ("Last error:
|
|
1164
|
+
// '17 passed'") teaches the agent to fix something that worked. Likewise a
|
|
1165
|
+
// bare exit code carries nothing to quote. Fall through to the generic text.
|
|
1166
|
+
if (snippet !== "" && !/^exit code \d+$/i.test(snippet) && !looksLikeSuccess(snippet)) {
|
|
523
1167
|
return `Last error: "${snippet}" — address that specific error before retrying this exact call.`
|
|
524
1168
|
}
|
|
525
1169
|
return "This exact call keeps failing — inspect the last output line and change approach before retrying."
|