@haystackeditor/cli 0.11.0 → 0.12.1
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 +9 -12
- package/dist/assets/hooks/agent-context/detect.ts +180 -0
- package/dist/assets/hooks/agent-context/format.ts +1 -0
- package/dist/assets/hooks/agent-context/index.ts +2 -0
- package/dist/assets/hooks/agent-context/parsers/claude.ts +14 -5
- package/dist/assets/hooks/agent-context/parsers/codex.ts +416 -0
- package/dist/assets/hooks/agent-context/tsconfig.json +1 -0
- package/dist/assets/hooks/agent-context/types.ts +1 -1
- package/dist/assets/hooks/package.json +2 -1
- package/dist/assets/hooks/scripts/pre-commit.sh +80 -2
- package/dist/assets/hooks/scripts/pre-push.sh +1 -1
- package/dist/assets/skills/submit.md +20 -0
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.js +15 -5
- package/dist/commands/install-session-hooks.d.ts +3 -2
- package/dist/commands/install-session-hooks.js +27 -11
- package/dist/commands/pr-status.d.ts +5 -0
- package/dist/commands/pr-status.js +2 -2
- package/dist/commands/setup.js +21 -10
- package/dist/commands/submit.d.ts +1 -0
- package/dist/commands/submit.js +11 -18
- package/dist/commands/triage.d.ts +16 -4
- package/dist/commands/triage.js +210 -203
- package/dist/index.d.ts +1 -1
- package/dist/index.js +31 -33
- package/dist/triage/traces.js +9 -3
- package/dist/types.d.ts +14 -0
- package/dist/types.js +2 -0
- package/package.json +1 -1
- package/dist/commands/check-pending.d.ts +0 -19
- package/dist/commands/check-pending.js +0 -217
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ Create a PR from current changes. Runs pre-PR triage (code review, rules validat
|
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
71
|
haystack submit # Triage -> create PR -> wait for analysis
|
|
72
|
+
haystack submit --auto-fix # Discouraged alpha auto-fix for straightforward mechanical issues
|
|
72
73
|
haystack submit --title "Fix auth" # Custom PR title
|
|
73
74
|
haystack submit --draft # Create as draft PR
|
|
74
75
|
haystack submit --force # Skip triage checks
|
|
@@ -84,30 +85,26 @@ haystack submit --review octocat # Request review from a specific teammate
|
|
|
84
85
|
|
|
85
86
|
When `--review` is used without a username, the PR is labeled `haystack:needs-review` and appears in your team's assignment queue. When a username is provided, that person is also requested as a reviewer on GitHub.
|
|
86
87
|
|
|
88
|
+
The `--auto-fix` flag is an alpha feature and is discouraged by default. It opts into a sandbox agent that attempts straightforward mechanical fixes before surfacing issues in the Feed. For most PRs, prefer plain `haystack submit`.
|
|
89
|
+
|
|
87
90
|
### `haystack triage`
|
|
88
91
|
|
|
89
92
|
View Haystack analysis results for any PR. Shows the same data as the Haystack web feed: rating, verdict, structured findings with details, verified bugs, human review reasons, and agent fix prompts.
|
|
90
93
|
|
|
91
94
|
```bash
|
|
95
|
+
haystack triage # Last submitted PR
|
|
92
96
|
haystack triage 42 # Current repo, PR #42
|
|
93
97
|
haystack triage owner/repo#99 # Fully qualified
|
|
94
98
|
haystack triage https://github.com/o/r/pull/1 # From GitHub URL
|
|
95
99
|
haystack triage 42 --json # Machine-readable JSON output
|
|
96
100
|
haystack triage 42 --no-wait # Don't wait if analysis is pending
|
|
101
|
+
haystack triage --hook # Minimal one-liner (for session hooks)
|
|
102
|
+
haystack triage --clear # Clear pending submit state
|
|
97
103
|
```
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
### `haystack check-pending`
|
|
102
|
-
|
|
103
|
-
Check the status of a previously submitted PR's analysis (from the current repo):
|
|
105
|
+
When called without a PR identifier, checks the last PR submitted via `haystack submit`. The `--hook` flag produces a single-line summary with the Haystack rating and auto-fixer status, designed for session-start hooks.
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
haystack check-pending # Rich terminal output
|
|
107
|
-
haystack check-pending --hook # Minimal output (for session-start hooks)
|
|
108
|
-
haystack check-pending --json # Machine-readable JSON
|
|
109
|
-
haystack check-pending --clear # Clear pending state
|
|
110
|
-
```
|
|
107
|
+
The `--json` output includes `agentFixPrompt` fields -- ready-to-paste instructions for coding agents to fix each finding.
|
|
111
108
|
|
|
112
109
|
### `haystack dismiss`
|
|
113
110
|
|
|
@@ -198,7 +195,7 @@ haystack hooks install --skip-entire # Only install Haystack hooks
|
|
|
198
195
|
haystack hooks status # Check installation status
|
|
199
196
|
haystack hooks update # Update Entire CLI to latest
|
|
200
197
|
|
|
201
|
-
# Session hooks (
|
|
198
|
+
# Session hooks (triage on CLI start)
|
|
202
199
|
haystack hooks install-session # Auto-detect CLIs
|
|
203
200
|
haystack hooks install-session --cli claude # Claude Code only
|
|
204
201
|
haystack hooks install-session --cli all # All detected CLIs
|
|
@@ -61,6 +61,120 @@ function extractSessionId(filePath: string, ext: string): string {
|
|
|
61
61
|
return path.basename(filePath, ext);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function findMostRecentFileRecursive(
|
|
65
|
+
dir: string,
|
|
66
|
+
matches: (filePath: string) => boolean,
|
|
67
|
+
): string | null {
|
|
68
|
+
if (!fs.existsSync(dir)) return null;
|
|
69
|
+
|
|
70
|
+
const stack = [dir];
|
|
71
|
+
let bestPath: string | null = null;
|
|
72
|
+
let bestMtime = 0;
|
|
73
|
+
|
|
74
|
+
while (stack.length > 0) {
|
|
75
|
+
const currentDir = stack.pop();
|
|
76
|
+
if (!currentDir) continue;
|
|
77
|
+
|
|
78
|
+
let entries: fs.Dirent[];
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
87
|
+
if (entry.isDirectory()) {
|
|
88
|
+
stack.push(fullPath);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!entry.isFile() || !matches(fullPath)) continue;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const stat = fs.statSync(fullPath);
|
|
95
|
+
if (stat.mtimeMs > bestMtime) {
|
|
96
|
+
bestMtime = stat.mtimeMs;
|
|
97
|
+
bestPath = fullPath;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return bestPath;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readCodexSessionRepoPath(filePath: string): string | null {
|
|
109
|
+
let fd: number | undefined;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
fd = fs.openSync(filePath, 'r');
|
|
113
|
+
const buffer = Buffer.alloc(32 * 1024);
|
|
114
|
+
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
115
|
+
const header = buffer.toString('utf8', 0, bytesRead);
|
|
116
|
+
|
|
117
|
+
for (const line of header.split('\n')) {
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
if (!trimmed) continue;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(trimmed) as {
|
|
123
|
+
type?: string;
|
|
124
|
+
payload?: { cwd?: string };
|
|
125
|
+
};
|
|
126
|
+
if (
|
|
127
|
+
(parsed.type === 'session_meta' || parsed.type === 'turn_context') &&
|
|
128
|
+
typeof parsed.payload?.cwd === 'string'
|
|
129
|
+
) {
|
|
130
|
+
return parsed.payload.cwd;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
} finally {
|
|
139
|
+
if (fd !== undefined) {
|
|
140
|
+
fs.closeSync(fd);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function codexSessionMatchesRepo(
|
|
148
|
+
sessionFilePath: string,
|
|
149
|
+
repoPath: string,
|
|
150
|
+
): boolean {
|
|
151
|
+
const sessionRepoPath = readCodexSessionRepoPath(sessionFilePath);
|
|
152
|
+
return !sessionRepoPath || sessionRepoPath === repoPath;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getCodexSessionRoots(): string[] {
|
|
156
|
+
const overrideDir = process.env.ENTIRE_TEST_CODEX_SESSION_DIR;
|
|
157
|
+
if (overrideDir) return [overrideDir];
|
|
158
|
+
|
|
159
|
+
const codexHome =
|
|
160
|
+
process.env.ENTIRE_TEST_CODEX_HOME ||
|
|
161
|
+
process.env.CODEX_HOME ||
|
|
162
|
+
path.join(os.homedir(), '.codex');
|
|
163
|
+
|
|
164
|
+
return [
|
|
165
|
+
path.join(codexHome, 'sessions'),
|
|
166
|
+
path.join(codexHome, 'archived_sessions'),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function extractCodexSessionId(filePath: string): string {
|
|
171
|
+
const basename = path.basename(filePath);
|
|
172
|
+
const match = basename.match(
|
|
173
|
+
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i,
|
|
174
|
+
);
|
|
175
|
+
return match ? match[1] : extractSessionId(filePath, '.jsonl');
|
|
176
|
+
}
|
|
177
|
+
|
|
64
178
|
function detectClaude(repoPath: string): DetectionResult {
|
|
65
179
|
const overrideDir = process.env.ENTIRE_TEST_CLAUDE_PROJECT_DIR;
|
|
66
180
|
const sessionDir =
|
|
@@ -119,8 +233,74 @@ function detectOpenCode(repoPath: string): DetectionResult {
|
|
|
119
233
|
};
|
|
120
234
|
}
|
|
121
235
|
|
|
236
|
+
function detectCodex(repoPath: string): DetectionResult {
|
|
237
|
+
const overrideFile = process.env.ENTIRE_TEST_CODEX_SESSION_FILE;
|
|
238
|
+
if (
|
|
239
|
+
overrideFile &&
|
|
240
|
+
isRecent(overrideFile) &&
|
|
241
|
+
codexSessionMatchesRepo(overrideFile, repoPath)
|
|
242
|
+
) {
|
|
243
|
+
return {
|
|
244
|
+
detected: true,
|
|
245
|
+
agent: 'codex',
|
|
246
|
+
sessionFilePath: overrideFile,
|
|
247
|
+
sessionId: extractCodexSessionId(overrideFile),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const sessionRoots = getCodexSessionRoots();
|
|
252
|
+
const threadId = process.env.CODEX_THREAD_ID;
|
|
253
|
+
|
|
254
|
+
if (threadId) {
|
|
255
|
+
for (const sessionRoot of sessionRoots) {
|
|
256
|
+
const sessionFile = findMostRecentFileRecursive(
|
|
257
|
+
sessionRoot,
|
|
258
|
+
(filePath) =>
|
|
259
|
+
filePath.endsWith(`${threadId}.jsonl`) &&
|
|
260
|
+
path.basename(filePath).startsWith('rollout-'),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
sessionFile &&
|
|
265
|
+
isRecent(sessionFile) &&
|
|
266
|
+
codexSessionMatchesRepo(sessionFile, repoPath)
|
|
267
|
+
) {
|
|
268
|
+
return {
|
|
269
|
+
detected: true,
|
|
270
|
+
agent: 'codex',
|
|
271
|
+
sessionFilePath: sessionFile,
|
|
272
|
+
sessionId: extractCodexSessionId(sessionFile),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const sessionRoot of sessionRoots) {
|
|
279
|
+
const sessionFile = findMostRecentFileRecursive(
|
|
280
|
+
sessionRoot,
|
|
281
|
+
(filePath) =>
|
|
282
|
+
filePath.endsWith('.jsonl') &&
|
|
283
|
+
path.basename(filePath).startsWith('rollout-') &&
|
|
284
|
+
isRecent(filePath) &&
|
|
285
|
+
codexSessionMatchesRepo(filePath, repoPath),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (sessionFile) {
|
|
289
|
+
return {
|
|
290
|
+
detected: true,
|
|
291
|
+
agent: 'codex',
|
|
292
|
+
sessionFilePath: sessionFile,
|
|
293
|
+
sessionId: extractCodexSessionId(sessionFile),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { detected: false };
|
|
299
|
+
}
|
|
300
|
+
|
|
122
301
|
const detectors: Array<(repoPath: string) => DetectionResult> = [
|
|
123
302
|
detectClaude,
|
|
303
|
+
detectCodex,
|
|
124
304
|
detectGemini,
|
|
125
305
|
detectOpenCode,
|
|
126
306
|
];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { detectAgent } from './detect.js';
|
|
2
2
|
import { ClaudeParser } from './parsers/claude.js';
|
|
3
|
+
import { CodexParser } from './parsers/codex.js';
|
|
3
4
|
import { GeminiParser } from './parsers/gemini.js';
|
|
4
5
|
import { OpenCodeParser } from './parsers/opencode.js';
|
|
5
6
|
import { formatContext } from './format.js';
|
|
@@ -7,6 +8,7 @@ import type { AgentParser } from './types.js';
|
|
|
7
8
|
|
|
8
9
|
const PARSERS: Record<string, AgentParser> = {
|
|
9
10
|
'claude-code': new ClaudeParser(),
|
|
11
|
+
codex: new CodexParser(),
|
|
10
12
|
'gemini-cli': new GeminiParser(),
|
|
11
13
|
opencode: new OpenCodeParser(),
|
|
12
14
|
};
|
|
@@ -25,6 +25,7 @@ interface ClaudeLine {
|
|
|
25
25
|
gitBranch?: string;
|
|
26
26
|
uuid?: string;
|
|
27
27
|
timestamp?: string;
|
|
28
|
+
// Old format: message wrapper with role/content/usage
|
|
28
29
|
message?: {
|
|
29
30
|
id?: string;
|
|
30
31
|
model?: string;
|
|
@@ -37,10 +38,13 @@ interface ClaudeLine {
|
|
|
37
38
|
cache_read_input_tokens?: number;
|
|
38
39
|
};
|
|
39
40
|
};
|
|
41
|
+
// New Entire CLI format: id/content at top level (type is the role)
|
|
42
|
+
id?: string;
|
|
43
|
+
content?: unknown;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
interface ContentBlock {
|
|
43
|
-
type
|
|
47
|
+
type?: string; // Optional: new format blocks may omit type
|
|
44
48
|
text?: string;
|
|
45
49
|
thinking?: string;
|
|
46
50
|
name?: string;
|
|
@@ -68,7 +72,8 @@ function extractTextContent(content: unknown): string {
|
|
|
68
72
|
|
|
69
73
|
const parts: string[] = [];
|
|
70
74
|
for (const block of content as ContentBlock[]) {
|
|
71
|
-
|
|
75
|
+
// Support both old ({type: "text", text}) and new ({id, text}) content blocks
|
|
76
|
+
if (block.text && (block.type === 'text' || !block.type)) {
|
|
72
77
|
parts.push(block.text);
|
|
73
78
|
}
|
|
74
79
|
}
|
|
@@ -143,9 +148,10 @@ export class ClaudeParser implements AgentParser {
|
|
|
143
148
|
const assistantLines = allLines.filter((l) => l.type === 'assistant');
|
|
144
149
|
|
|
145
150
|
// Deduplicate assistant messages by message.id (streaming produces duplicates)
|
|
151
|
+
// Support both old format (line.message.id) and new format (line.id)
|
|
146
152
|
const dedupedAssistant = new Map<string, ClaudeLine>();
|
|
147
153
|
for (const line of assistantLines) {
|
|
148
|
-
const msgId = line.message?.id;
|
|
154
|
+
const msgId = line.message?.id || line.id;
|
|
149
155
|
if (msgId) {
|
|
150
156
|
dedupedAssistant.set(msgId, line);
|
|
151
157
|
}
|
|
@@ -162,17 +168,20 @@ export class ClaudeParser implements AgentParser {
|
|
|
162
168
|
for (let i = 0; i < maxTurns; i++) {
|
|
163
169
|
if (i < userLines.length) {
|
|
164
170
|
const user = userLines[i];
|
|
171
|
+
// Support both old ({message: {content}}) and new ({content}) formats
|
|
172
|
+
const userContent = user.message?.content ?? user.content;
|
|
165
173
|
transcript.push({
|
|
166
174
|
turnIndex: turnIndex++,
|
|
167
175
|
role: 'user',
|
|
168
|
-
content: extractTextContent(
|
|
176
|
+
content: extractTextContent(userContent),
|
|
169
177
|
modifiedFiles: [],
|
|
170
178
|
toolUses: [],
|
|
171
179
|
});
|
|
172
180
|
}
|
|
173
181
|
if (i < uniqueAssistant.length) {
|
|
174
182
|
const asst = uniqueAssistant[i];
|
|
175
|
-
|
|
183
|
+
// Support both old ({message: {content, usage}}) and new ({content}) formats
|
|
184
|
+
const content = asst.message?.content ?? asst.content;
|
|
176
185
|
const usage = asst.message?.usage;
|
|
177
186
|
transcript.push({
|
|
178
187
|
turnIndex: turnIndex++,
|