@haystackeditor/cli 0.10.3 → 0.12.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.
Files changed (42) hide show
  1. package/README.md +15 -16
  2. package/dist/assets/hooks/agent-context/detect.ts +180 -0
  3. package/dist/assets/hooks/agent-context/format.ts +1 -0
  4. package/dist/assets/hooks/agent-context/index.ts +2 -0
  5. package/dist/assets/hooks/agent-context/parsers/claude.ts +14 -5
  6. package/dist/assets/hooks/agent-context/parsers/codex.ts +416 -0
  7. package/dist/assets/hooks/agent-context/tsconfig.json +1 -0
  8. package/dist/assets/hooks/agent-context/types.ts +1 -1
  9. package/dist/assets/hooks/package.json +2 -1
  10. package/dist/assets/hooks/scripts/pre-commit.sh +80 -2
  11. package/dist/assets/hooks/scripts/pre-push.sh +1 -1
  12. package/dist/assets/skills/submit.md +20 -0
  13. package/dist/commands/config.d.ts +4 -0
  14. package/dist/commands/config.js +94 -69
  15. package/dist/commands/dismiss.js +2 -1
  16. package/dist/commands/install-session-hooks.d.ts +3 -2
  17. package/dist/commands/install-session-hooks.js +27 -11
  18. package/dist/commands/pr-status.d.ts +5 -0
  19. package/dist/commands/pr-status.js +2 -2
  20. package/dist/commands/request-review.d.ts +15 -0
  21. package/dist/commands/request-review.js +95 -0
  22. package/dist/commands/setup.d.ts +1 -0
  23. package/dist/commands/setup.js +285 -2
  24. package/dist/commands/submit.d.ts +1 -0
  25. package/dist/commands/submit.js +12 -19
  26. package/dist/commands/triage.d.ts +16 -4
  27. package/dist/commands/triage.js +278 -179
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.js +73 -50
  30. package/dist/triage/prompts.js +13 -5
  31. package/dist/triage/runner.js +1 -1
  32. package/dist/triage/traces.js +9 -3
  33. package/dist/triage/types.d.ts +1 -1
  34. package/dist/types.d.ts +146 -26
  35. package/dist/types.js +44 -5
  36. package/dist/utils/analysis-api.d.ts +16 -0
  37. package/dist/utils/analysis-api.js +23 -5
  38. package/dist/utils/telemetry.d.ts +17 -0
  39. package/dist/utils/telemetry.js +109 -0
  40. package/package.json +1 -1
  41. package/dist/commands/check-pending.d.ts +0 -19
  42. package/dist/commands/check-pending.js +0 -217
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @haystackeditor/cli
2
2
 
3
- Set up Haystack for your project. When PRs are opened, Haystack automatically reviews them for bugs, intent drift, and rule violations — then routes them to the right place.
3
+ Set up Haystack for your project. When PRs are opened, Haystack automatically reviews them for bugs, instruction drift, and rule violations — then routes them to the right place.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -65,10 +65,11 @@ haystack logout
65
65
 
66
66
  ### `haystack submit`
67
67
 
68
- Create a PR from current changes. Runs pre-PR triage (code review, rules validation, intent drift), pushes your branch, and opens the PR.
68
+ Create a PR from current changes. Runs pre-PR triage (code review, rules validation, instruction drift), pushes your branch, and opens the PR.
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
- The `--json` output includes `agentFixPrompt` fields -- ready-to-paste instructions for coding agents to fix each finding.
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
- ```bash
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
 
@@ -163,12 +160,14 @@ haystack config auto-merge # Show current status
163
160
  haystack config auto-merge on # Enable auto-merge
164
161
  haystack config auto-merge off # Disable auto-merge
165
162
 
166
- # AI reviewer wait list
163
+ # AI reviewer wait list (merge queue waits for ALL configured bots before merging)
167
164
  haystack config wait-for-reviewers # Show status
168
165
  haystack config wait-for-reviewers add cursor # Wait for Cursor BugBot
169
166
  haystack config wait-for-reviewers add cursor coderabbit # Add multiple
170
167
  haystack config wait-for-reviewers remove cursor # Stop waiting
171
168
  haystack config wait-for-reviewers clear # Wait for none
169
+ # Also accepts raw GitHub bot usernames:
170
+ haystack config wait-for-reviewers add cursor-bugbot[bot]
172
171
  ```
173
172
 
174
173
  ### `haystack skills`
@@ -196,7 +195,7 @@ haystack hooks install --skip-entire # Only install Haystack hooks
196
195
  haystack hooks status # Check installation status
197
196
  haystack hooks update # Update Entire CLI to latest
198
197
 
199
- # Session hooks (check-pending on CLI start)
198
+ # Session hooks (triage on CLI start)
200
199
  haystack hooks install-session # Auto-detect CLIs
201
200
  haystack hooks install-session --cli claude # Claude Code only
202
201
  haystack hooks install-session --cli all # All detected CLIs
@@ -244,7 +243,7 @@ The `setup` wizard writes `.haystack.json` to your repos with discovered rules,
244
243
  1. Run `haystack setup` to configure your repos (or `haystack init` for local-only config)
245
244
  2. Install the [Haystack GitHub App](https://haystackeditor.com/github-app)
246
245
  3. When PRs are opened, Haystack automatically:
247
- - Analyzes the code for bugs, intent drift, and rule violations
246
+ - Analyzes the code for bugs, instruction drift, and rule violations
248
247
  - Reports results on the PR
249
248
  - Routes the PR to the right inbox tab (Good to Merge, Issues Found, etc.)
250
249
  - If auto-merge is enabled, clean PRs merge automatically
@@ -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
  ];
@@ -3,6 +3,7 @@ import type { AgentContext, InteractionTurn } from './types.js';
3
3
  const AGENT_DISPLAY_NAMES: Record<string, string> = {
4
4
  'claude-code': 'Claude Code',
5
5
  'gemini-cli': 'Gemini CLI',
6
+ codex: 'Codex',
6
7
  opencode: 'OpenCode',
7
8
  };
8
9
 
@@ -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: string;
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
- if (block.type === 'text' && block.text) {
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(user.message?.content),
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
- const content = asst.message?.content;
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++,