triage-trip 0.1.3 → 0.1.4
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/README.md +0 -6
- package/bin/cli.js +11 -4
- package/package.json +4 -2
- package/src/triage.js +79 -24
- package/src/utils.js +63 -0
package/README.md
CHANGED
|
@@ -115,9 +115,3 @@ npm link
|
|
|
115
115
|
- **Cursor Prompt Provenance:** Direct read-only extraction from local `state.vscdb` to link regressions to specific Composer prompts.
|
|
116
116
|
- **Root Cause Isolation:** LLM-assisted semantic contract shift detection.
|
|
117
117
|
- **Interactive Patching:** Clean, in-memory `git apply` with user confirmation prompts (`--yes` fallback).
|
|
118
|
-
|
|
119
|
-
### On the Horizon
|
|
120
|
-
- [ ] **Automated Test Verification:** Automatically run project test suites (`npm test`, `pytest`) to verify fixes before finalizing patches.
|
|
121
|
-
- [ ] **Expanded IDE Provenance:** Support for Windsurf (`cascade`), VS Code Copilot Chat, and Claude Code logs.
|
|
122
|
-
- [ ] **GitHub Action / CI Mode:** Comment directly on failed PR runs with the attributed prompt and proposed fix.
|
|
123
|
-
- [ ] **Deterministic AST Diffing:** Local offline schema diffing before LLM invocation to cut latency and token usage.
|
package/bin/cli.js
CHANGED
|
@@ -37,12 +37,19 @@ program
|
|
|
37
37
|
]);
|
|
38
38
|
|
|
39
39
|
spinner.text = 'Isolating structural contract shifts and AST regressions...';
|
|
40
|
-
diagnosis = await triageRegression(errorInput, changes, prompts)
|
|
40
|
+
diagnosis = await triageRegression(errorInput, changes, prompts, () => {
|
|
41
|
+
// Stop spinner before the first streamed token so ora doesn't garble stdout
|
|
42
|
+
spinner.stop();
|
|
43
|
+
process.stdout.write('\n');
|
|
44
|
+
});
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
console.log('\n' + diagnosis + '\n');
|
|
46
|
+
console.log('\n' + pc.green('✓ Root cause isolated.') + '\n');
|
|
44
47
|
} catch (err) {
|
|
45
|
-
spinner.
|
|
48
|
+
if (spinner.isSpinning) {
|
|
49
|
+
spinner.fail(pc.red('Triage failed: ' + err.message));
|
|
50
|
+
} else {
|
|
51
|
+
console.error(pc.red('\nTriage failed: ' + err.message));
|
|
52
|
+
}
|
|
46
53
|
process.exit(1);
|
|
47
54
|
}
|
|
48
55
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triage-trip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Autonomous CLI to pinpoint and patch regressions introduced by AI code assistants",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node test/patch.test.js"
|
|
7
|
+
"test": "node test/patch.test.js && node test/simulation.test.js && node test/streaming.test.js",
|
|
8
|
+
"test:sim": "node test/simulation.test.js",
|
|
9
|
+
"test:stream": "node test/streaming.test.js"
|
|
8
10
|
},
|
|
9
11
|
"keywords": [
|
|
10
12
|
"cursor",
|
package/src/triage.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
// src/triage.js
|
|
2
2
|
import OpenAI from 'openai';
|
|
3
3
|
import Anthropic from '@anthropic-ai/sdk';
|
|
4
|
+
import { cleanGitContext } from './utils.js';
|
|
5
|
+
|
|
6
|
+
export async function triageRegression(
|
|
7
|
+
errorDescription,
|
|
8
|
+
gitContext,
|
|
9
|
+
editorPrompts = [],
|
|
10
|
+
onStreamStart,
|
|
11
|
+
deps = {},
|
|
12
|
+
) {
|
|
13
|
+
if (!errorDescription || typeof errorDescription !== 'string' || !errorDescription.trim()) {
|
|
14
|
+
throw new Error('Query or error trace cannot be empty.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const AnthropicClient = deps.Anthropic || Anthropic;
|
|
18
|
+
const OpenAIClient = deps.OpenAI || OpenAI;
|
|
4
19
|
|
|
5
|
-
export async function triageRegression(errorDescription, gitContext, cursorPrompts = []) {
|
|
6
20
|
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
7
21
|
const openaiKey = process.env.OPENAI_API_KEY;
|
|
8
22
|
|
|
@@ -23,38 +37,49 @@ When generating the "Patch" section:
|
|
|
23
37
|
- Ensure @@ -line,count +line,count @@ markers accurately match the context.
|
|
24
38
|
- Include 1-2 lines of unchanged surrounding context above and below the change.
|
|
25
39
|
- Only touch lines that actually exist in the file.
|
|
26
|
-
-
|
|
40
|
+
- The '-' (removed) lines MUST match the EXACT text, indentation, and casing from the active working file on disk.
|
|
27
41
|
`.trim();
|
|
28
42
|
|
|
29
|
-
const
|
|
30
|
-
|
|
43
|
+
const safeGitContext = cleanGitContext(gitContext);
|
|
44
|
+
|
|
45
|
+
const activeFilesSection = safeGitContext.activeFileContents && Object.keys(safeGitContext.activeFileContents).length > 0
|
|
46
|
+
? `Active Working Files on Disk (Current File Contents & Line Numbers):\n${JSON.stringify(safeGitContext.activeFileContents, null, 2)}`
|
|
31
47
|
: '';
|
|
32
48
|
|
|
49
|
+
const hasPrompts = Array.isArray(editorPrompts) && editorPrompts.length > 0;
|
|
50
|
+
const promptsSection = hasPrompts
|
|
51
|
+
? `Recent IDE / Editor Prompts:\n${JSON.stringify(editorPrompts, null, 2)}`
|
|
52
|
+
: 'No local IDE prompt telemetry available. Attribute cause using Git commit messages and diffs.';
|
|
53
|
+
|
|
54
|
+
const attributedStepLabel = hasPrompts ? 'Attributed Prompt' : 'Attributed Intent';
|
|
55
|
+
const attributedStepDetail = hasPrompts
|
|
56
|
+
? 'Name the specific IDE/editor prompt that likely caused this diff (if found in the list; otherwise state none).'
|
|
57
|
+
: 'Infer the likely author intent from Git commit messages and diffs (no IDE prompt telemetry available).';
|
|
58
|
+
|
|
33
59
|
const userPrompt = `
|
|
34
60
|
User problem / error trace:
|
|
35
61
|
"${errorDescription}"
|
|
36
62
|
|
|
37
63
|
${activeFilesSection}
|
|
38
64
|
|
|
39
|
-
|
|
40
|
-
${JSON.stringify(cursorPrompts, null, 2)}
|
|
65
|
+
${promptsSection}
|
|
41
66
|
|
|
42
67
|
Recent Repository Git Stream (Working diff + Commits):
|
|
43
68
|
${JSON.stringify({
|
|
44
|
-
branch:
|
|
45
|
-
untrackedFiles:
|
|
46
|
-
workingDiff:
|
|
47
|
-
recentCommits:
|
|
69
|
+
branch: safeGitContext.branch,
|
|
70
|
+
untrackedFiles: safeGitContext.untrackedFiles,
|
|
71
|
+
workingDiff: safeGitContext.workingDiff,
|
|
72
|
+
recentCommits: safeGitContext.recentCommits,
|
|
48
73
|
}, null, 2)}
|
|
49
74
|
|
|
50
75
|
Provide a strict, concise triage output formatted with terminal-friendly Markdown:
|
|
51
76
|
1. **Root Cause**: Pinpoint the file, line, and commit/working diff where the breaking change occurred.
|
|
52
|
-
2.
|
|
77
|
+
2. **${attributedStepLabel}**: ${attributedStepDetail}
|
|
53
78
|
3. **The Shift**: Explain what broke (e.g., schema key rename, dropped parameter, async/await omission).
|
|
54
79
|
4. **Patch**: Provide the exact unified diff adhering strictly to the system diff formatting instructions.
|
|
55
80
|
|
|
56
81
|
CRITICAL RULES FOR THE PATCH:
|
|
57
|
-
- The '-' (removed) lines MUST match the EXACT text and casing currently in the file shown in "Active Working Files on Disk".
|
|
82
|
+
- The '-' (removed) lines MUST match the EXACT text, indentation, and casing currently in the file shown in "Active Working Files on Disk".
|
|
58
83
|
- Target the file accurately:
|
|
59
84
|
--- a/<path>
|
|
60
85
|
+++ b/<path>
|
|
@@ -63,35 +88,65 @@ CRITICAL RULES FOR THE PATCH:
|
|
|
63
88
|
- Only change the breaking line(s). Keep 1-2 lines of unchanged surrounding context lines above and below without '+' or '-'.
|
|
64
89
|
- Example format:
|
|
65
90
|
\`\`\`diff
|
|
66
|
-
--- a/app.js
|
|
67
|
-
+++ b/app.js
|
|
68
|
-
@@ -
|
|
91
|
+
--- a/src/app.js
|
|
92
|
+
+++ b/src/app.js
|
|
93
|
+
@@ -5,3 +5,3 @@
|
|
94
|
+
const profile = getUserProfile();
|
|
69
95
|
- console.log(\`Loading dashboard for user ID: \${profile.userid.toUpperCase()}\`);
|
|
70
96
|
+ console.log(\`Loading dashboard for user ID: \${profile.user_id.toUpperCase()}\`);
|
|
97
|
+
return profile;
|
|
71
98
|
\`\`\`
|
|
72
99
|
`;
|
|
73
100
|
|
|
101
|
+
let streamStarted = false;
|
|
102
|
+
const emitChunk = (text) => {
|
|
103
|
+
if (!text) return;
|
|
104
|
+
if (!streamStarted) {
|
|
105
|
+
streamStarted = true;
|
|
106
|
+
if (typeof onStreamStart === 'function') onStreamStart();
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write(text);
|
|
109
|
+
};
|
|
110
|
+
|
|
74
111
|
// 1. Prefer Anthropic if key exists
|
|
75
112
|
if (anthropicKey) {
|
|
76
|
-
const anthropic = new
|
|
77
|
-
const
|
|
78
|
-
model: 'claude-3-5-sonnet-
|
|
113
|
+
const anthropic = new AnthropicClient({ apiKey: anthropicKey });
|
|
114
|
+
const stream = await anthropic.messages.create({
|
|
115
|
+
model: process.env.ANTHROPIC_MODEL || 'claude-3-5-sonnet-latest',
|
|
79
116
|
max_tokens: 1500,
|
|
80
117
|
system: systemInstructions,
|
|
81
|
-
messages: [{ role: 'user', content: userPrompt }]
|
|
118
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
119
|
+
stream: true,
|
|
82
120
|
});
|
|
83
|
-
|
|
121
|
+
|
|
122
|
+
let fullText = '';
|
|
123
|
+
for await (const event of stream) {
|
|
124
|
+
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
125
|
+
const chunk = event.delta.text || '';
|
|
126
|
+
emitChunk(chunk);
|
|
127
|
+
fullText += chunk;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return fullText;
|
|
84
131
|
}
|
|
85
132
|
|
|
86
133
|
// 2. Fallback to OpenAI
|
|
87
|
-
const openai = new
|
|
88
|
-
const
|
|
89
|
-
model: 'gpt-4o
|
|
134
|
+
const openai = new OpenAIClient({ apiKey: openaiKey });
|
|
135
|
+
const stream = await openai.chat.completions.create({
|
|
136
|
+
model: process.env.OPENAI_MODEL || 'gpt-4o',
|
|
90
137
|
temperature: 0.1,
|
|
138
|
+
stream: true,
|
|
91
139
|
messages: [
|
|
92
140
|
{ role: 'system', content: systemInstructions },
|
|
93
141
|
{ role: 'user', content: userPrompt }
|
|
94
142
|
]
|
|
95
143
|
});
|
|
96
|
-
|
|
144
|
+
|
|
145
|
+
let fullText = '';
|
|
146
|
+
for await (const chunk of stream) {
|
|
147
|
+
const text = chunk.choices[0]?.delta?.content || '';
|
|
148
|
+
emitChunk(text);
|
|
149
|
+
fullText += text;
|
|
150
|
+
}
|
|
151
|
+
return fullText;
|
|
97
152
|
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const LOCKFILE_NAMES = new Set([
|
|
2
|
+
'package-lock.json',
|
|
3
|
+
'yarn.lock',
|
|
4
|
+
'pnpm-lock.yaml',
|
|
5
|
+
'bun.lockb',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const MAX_DIFF_CHARS = 3000;
|
|
9
|
+
const MAX_FILE_CHARS = 3000;
|
|
10
|
+
|
|
11
|
+
function isLockfile(filePath) {
|
|
12
|
+
if (!filePath || typeof filePath !== 'string') return false;
|
|
13
|
+
const base = filePath.split(/[/\\]/).pop();
|
|
14
|
+
return LOCKFILE_NAMES.has(base);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function truncateDiff(diffStr) {
|
|
18
|
+
if (!diffStr || typeof diffStr !== 'string') return diffStr ?? null;
|
|
19
|
+
if (diffStr.length <= MAX_DIFF_CHARS) return diffStr;
|
|
20
|
+
return diffStr.slice(0, MAX_DIFF_CHARS) + '...[Diff truncated for length]';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function truncateFileContent(content) {
|
|
24
|
+
if (!content || typeof content !== 'string') return content;
|
|
25
|
+
if (content.length <= MAX_FILE_CHARS) return content;
|
|
26
|
+
return content.slice(0, MAX_FILE_CHARS) + '...[File truncated for length]';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Filters lockfiles and truncates large diffs/file contents to keep LLM prompts within context limits.
|
|
31
|
+
*/
|
|
32
|
+
export function cleanGitContext(gitContext = {}) {
|
|
33
|
+
const untrackedFiles = Array.isArray(gitContext.untrackedFiles)
|
|
34
|
+
? gitContext.untrackedFiles.filter((f) => !isLockfile(f))
|
|
35
|
+
: gitContext.untrackedFiles;
|
|
36
|
+
|
|
37
|
+
const workingDiff = truncateDiff(gitContext.workingDiff);
|
|
38
|
+
|
|
39
|
+
const recentCommits = Array.isArray(gitContext.recentCommits)
|
|
40
|
+
? gitContext.recentCommits.map((commit) => ({
|
|
41
|
+
...commit,
|
|
42
|
+
diff: truncateDiff(commit?.diff),
|
|
43
|
+
}))
|
|
44
|
+
: gitContext.recentCommits;
|
|
45
|
+
|
|
46
|
+
let activeFileContents = gitContext.activeFileContents;
|
|
47
|
+
if (activeFileContents && typeof activeFileContents === 'object') {
|
|
48
|
+
activeFileContents = Object.fromEntries(
|
|
49
|
+
Object.entries(activeFileContents).map(([path, content]) => [
|
|
50
|
+
path,
|
|
51
|
+
truncateFileContent(content),
|
|
52
|
+
])
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...gitContext,
|
|
58
|
+
untrackedFiles,
|
|
59
|
+
workingDiff,
|
|
60
|
+
recentCommits,
|
|
61
|
+
activeFileContents,
|
|
62
|
+
};
|
|
63
|
+
}
|