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 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
- spinner.succeed(pc.green('Root cause isolated.'));
43
- console.log('\n' + diagnosis + '\n');
46
+ console.log('\n' + pc.green('✓ Root cause isolated.') + '\n');
44
47
  } catch (err) {
45
- spinner.fail(pc.red('Triage failed: ' + err.message));
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",
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
- - Match the exact current text and casing from the active working file on disk as the '-' (removed) lines.
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 activeFilesSection = gitContext.activeFileContents && Object.keys(gitContext.activeFileContents).length > 0
30
- ? `Active Working Files on Disk (Current File Contents & Line Numbers):\n${JSON.stringify(gitContext.activeFileContents, null, 2)}`
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
- Recent Cursor Composer Prompts:
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: gitContext.branch,
45
- untrackedFiles: gitContext.untrackedFiles,
46
- workingDiff: gitContext.workingDiff,
47
- recentCommits: gitContext.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. **Attributed Prompt**: Name the specific Cursor prompt that likely caused this diff (if found in the list; otherwise state none).
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
- @@ -6,3 +6,3 @@
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 Anthropic({ apiKey: anthropicKey });
77
- const response = await anthropic.messages.create({
78
- model: 'claude-3-5-sonnet-20241022',
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
- return response.content[0].text;
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 OpenAI({ apiKey: openaiKey });
88
- const response = await openai.chat.completions.create({
89
- model: 'gpt-4o-mini',
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
- return response.choices[0].message.content;
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
+ }