@haystackeditor/cli 0.16.0 → 0.17.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 +31 -2
- package/dist/commands/case-batch-contract.js +93 -7
- package/dist/commands/case-batch.js +327 -29
- package/dist/commands/submit.js +32 -69
- package/dist/commands/verify-precompute.js +7 -1
- package/dist/index.js +56 -20
- package/dist/schema.js +2 -2
- package/dist/triage/astra.js +202 -0
- package/dist/triage/prompts.js +145 -180
- package/dist/triage/runner.js +99 -381
- package/dist/triage/types.js +2 -2
- package/dist/types.js +2 -6
- package/dist/utils/haystack-api.js +12 -0
- package/package.json +2 -1
- package/schemas/case-batch.v1.json +54 -3
- package/schemas/submit.v1.json +4 -2
package/dist/triage/runner.js
CHANGED
|
@@ -1,39 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Triage runner —
|
|
2
|
+
* Triage runner — runs the pre-PR checkers in parallel and aggregates results.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Each checker (code-review, rules-validator) is one structured Responses API
|
|
5
|
+
* call to gpt-6-astra at reasoning effort xhigh (see astra.ts). A checker
|
|
6
|
+
* that fails is reported as "<checker> failed: <error>" and never blocks
|
|
7
|
+
* submit; there is no second reviewer to fall back to.
|
|
6
8
|
*/
|
|
7
|
-
import { execFileSync
|
|
9
|
+
import { execFileSync } from 'child_process';
|
|
8
10
|
import { existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
9
11
|
import { join } from 'path';
|
|
10
12
|
import chalk from 'chalk';
|
|
11
|
-
import { buildCodeReviewPrompt, buildRulesValidatorPrompt } from './prompts.js';
|
|
13
|
+
import { buildCodeReviewPrompt, buildRulesValidatorPrompt, CODE_REVIEW_SCHEMA, RULES_VALIDATOR_SCHEMA, } from './prompts.js';
|
|
14
|
+
import { callAstra, TRIAGE_MODEL, TRIAGE_REASONING_EFFORT } from './astra.js';
|
|
12
15
|
import { resolveDiffBaseRef } from '../utils/git.js';
|
|
13
16
|
import { trackError } from '../utils/telemetry.js';
|
|
14
17
|
// ============================================================================
|
|
15
18
|
// Constants
|
|
16
19
|
// ============================================================================
|
|
17
20
|
const TRIAGE_DIR = '.haystack/triage';
|
|
18
|
-
/** Diffs up to this many characters are embedded in the prompt; larger ones are read from the diff file. */
|
|
19
|
-
const MAX_INLINE_DIFF_CHARS = 100_000;
|
|
20
21
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* Abort a checker when its response stream has been silent this long.
|
|
23
|
+
* Overridable via .haystack.json triage.timeoutMs or --triage-timeout.
|
|
24
|
+
* Measured 2026-09-24: a max-effort review of a 20 KB diff streamed its
|
|
25
|
+
* longest silent gap at 14.6s.
|
|
24
26
|
*/
|
|
25
|
-
const
|
|
26
|
-
/** Wall-clock timeout per checker. Overridable via .haystack.json triage.timeoutMs or --triage-timeout. */
|
|
27
|
-
const DEFAULT_TIMEOUT_MS = 180_000; // 3 minutes
|
|
28
|
-
/**
|
|
29
|
-
* Default max agentic turns per checker. Overridable via .haystack.json
|
|
30
|
-
* triage.maxTurns or --max-turns. code-review tends to saturate first because
|
|
31
|
-
* it reads the diff line-by-line; 8 is the breakeven we landed on empirically.
|
|
32
|
-
*/
|
|
33
|
-
const DEFAULT_MAX_TURNS = {
|
|
34
|
-
'code-review': 8,
|
|
35
|
-
'rules-validator': 10,
|
|
36
|
-
};
|
|
27
|
+
const DEFAULT_STALL_MS = 300_000;
|
|
37
28
|
// ============================================================================
|
|
38
29
|
// Agent policy file discovery
|
|
39
30
|
// ============================================================================
|
|
@@ -62,287 +53,53 @@ function discoverAgentPolicyFiles(gitRoot) {
|
|
|
62
53
|
}
|
|
63
54
|
return results;
|
|
64
55
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
* Detect which agentic CLI is installed.
|
|
70
|
-
* Returns the first one found, or null if none available.
|
|
71
|
-
*/
|
|
72
|
-
export function detectAgenticCLI() {
|
|
73
|
-
const isWindows = process.platform === 'win32';
|
|
74
|
-
const inCodexSession = Boolean(process.env.CODEX_SHELL || process.env.CODEX_THREAD_ID || process.env.CODEX_CI);
|
|
75
|
-
const inClaudeSession = Boolean(process.env.CLAUDE_CODE || process.env.CLAUDECODE);
|
|
76
|
-
const clis = inCodexSession
|
|
77
|
-
? ['codex', 'claude', 'gemini']
|
|
78
|
-
: inClaudeSession
|
|
79
|
-
? ['claude', 'codex', 'gemini']
|
|
80
|
-
: ['claude', 'codex', 'gemini'];
|
|
81
|
-
for (const name of clis) {
|
|
82
|
-
try {
|
|
83
|
-
execFileSync(isWindows ? 'where' : 'which', [name], { stdio: 'ignore' });
|
|
84
|
-
return name;
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// Not installed
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Thrown when the detected CLI has no way to confine the triage sub-agent to
|
|
94
|
-
* read-only tools. Triage refuses to run there rather than run unconfined.
|
|
95
|
-
*/
|
|
96
|
-
export class UnrestrictableTriageCLIError extends Error {
|
|
97
|
-
constructor(cli) {
|
|
98
|
-
super(`Triage cannot run on ${cli}: it has no flag that denies shell and file-write tools to the sub-agent. ` +
|
|
99
|
-
'Install Claude Code (claude) or Codex CLI (codex), or pass --force to skip triage.');
|
|
100
|
-
this.name = 'UnrestrictableTriageCLIError';
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Build the CLI command + args for the detected agentic tool.
|
|
105
|
-
*
|
|
106
|
-
* Every runner is confined to read-only access: the prompt includes
|
|
107
|
-
* repo-controlled text, so a hostile pr-rules.yml, CLAUDE.md or diff must not
|
|
108
|
-
* be able to run commands or write files on the developer's machine.
|
|
109
|
-
* - claude: only the Read/Grep/Glob built-in tools exist in the session, no
|
|
110
|
-
* permission bypass, no MCP servers, and project/local settings (which can
|
|
111
|
-
* carry hooks and allow rules) are not loaded.
|
|
112
|
-
* - codex: the read-only sandbox (no writes, no network) with approvals off.
|
|
113
|
-
* - gemini: refused; its CLI cannot deny its shell tool from the command line.
|
|
114
|
-
*/
|
|
115
|
-
export function buildCommand(cli, prompt, maxTurns) {
|
|
116
|
-
switch (cli) {
|
|
117
|
-
case 'claude':
|
|
118
|
-
return {
|
|
119
|
-
command: 'claude',
|
|
120
|
-
args: [
|
|
121
|
-
'-p', prompt,
|
|
122
|
-
'--max-turns', String(maxTurns),
|
|
123
|
-
'--tools', CLAUDE_READ_ONLY_TOOLS,
|
|
124
|
-
'--allowedTools', CLAUDE_READ_ONLY_TOOLS,
|
|
125
|
-
'--strict-mcp-config',
|
|
126
|
-
'--setting-sources', 'user',
|
|
127
|
-
],
|
|
128
|
-
};
|
|
129
|
-
case 'codex':
|
|
130
|
-
return {
|
|
131
|
-
command: 'codex',
|
|
132
|
-
args: [
|
|
133
|
-
'--sandbox', 'read-only',
|
|
134
|
-
'--ask-for-approval', 'never',
|
|
135
|
-
'exec',
|
|
136
|
-
'--ephemeral',
|
|
137
|
-
prompt,
|
|
138
|
-
],
|
|
139
|
-
};
|
|
140
|
-
case 'gemini':
|
|
141
|
-
throw new UnrestrictableTriageCLIError(cli);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
56
|
+
const CHECKER_LABEL = {
|
|
57
|
+
'code-review': 'code review',
|
|
58
|
+
'rules-validator': 'rules validator',
|
|
59
|
+
};
|
|
144
60
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
61
|
+
* Map a schema-conformant reply onto the CheckerResult shape submit consumes.
|
|
62
|
+
* Strict mode enforces types, enums and required keys but not non-empty
|
|
63
|
+
* strings; a finding with a blank file or summary is left out and counted in
|
|
64
|
+
* `malformed` so the runner reports it, without discarding the other findings.
|
|
147
65
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
proc.stderr?.on('data', (data) => {
|
|
165
|
-
stderr += data.toString();
|
|
166
|
-
});
|
|
167
|
-
// Timeout
|
|
168
|
-
const timer = setTimeout(() => {
|
|
169
|
-
proc.kill('SIGTERM');
|
|
170
|
-
stderr += `\nProcess killed after ${Math.round(timeoutMs / 1000)}s timeout`;
|
|
171
|
-
}, timeoutMs);
|
|
172
|
-
proc.on('close', (code) => {
|
|
173
|
-
clearTimeout(timer);
|
|
174
|
-
resolve({ exitCode: code ?? 1, stdout, output: `${stdout}\n${stderr}`.trim() });
|
|
175
|
-
});
|
|
176
|
-
proc.on('error', (err) => {
|
|
177
|
-
clearTimeout(timer);
|
|
178
|
-
resolve({ exitCode: 1, stdout: '', output: err.message });
|
|
179
|
-
});
|
|
66
|
+
function toCheckerResult(name, reply) {
|
|
67
|
+
const usable = reply.findings.filter(finding => finding.file.trim() && finding.summary.trim());
|
|
68
|
+
const malformed = reply.findings.length - usable.length;
|
|
69
|
+
const issues = usable.map((finding) => {
|
|
70
|
+
const issue = {
|
|
71
|
+
file: finding.file,
|
|
72
|
+
severity: finding.severity,
|
|
73
|
+
message: finding.summary,
|
|
74
|
+
category: finding.category,
|
|
75
|
+
failureScenario: finding.failureScenario,
|
|
76
|
+
};
|
|
77
|
+
if (typeof finding.line === 'number' && finding.line > 0)
|
|
78
|
+
issue.line = finding.line;
|
|
79
|
+
if (finding.rule)
|
|
80
|
+
issue.rule = finding.rule;
|
|
81
|
+
return issue;
|
|
180
82
|
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
* the diff is too large for the budget; should not block submit.
|
|
187
|
-
* - `timeout`: we killed the process at the configured wall-clock cap. Also non-blocking.
|
|
188
|
-
* - `error`: anything else (auth failure, crash, malformed output). Blocks.
|
|
189
|
-
*
|
|
190
|
-
* The max-turns markers come from the CLIs themselves (Claude CLI prints
|
|
191
|
-
* "Reached max turns", Codex prints "turn limit"). If a new CLI is added, add
|
|
192
|
-
* its marker here — unknown failures fall through to `error` (safe default).
|
|
193
|
-
*/
|
|
194
|
-
function classifyCheckerFailure(output) {
|
|
195
|
-
const summary = summarizeCheckerFailureOutput(output) ?? '(no output)';
|
|
196
|
-
// Claude: "Reached max turns (N)". Codex/Gemini variants: "turn limit", "max iterations".
|
|
197
|
-
if (/reached max turns|max turns reached|turn limit|max iterations/i.test(output)) {
|
|
198
|
-
return { kind: 'max_turns', summary };
|
|
199
|
-
}
|
|
200
|
-
// Our own SIGTERM message from spawnChecker.
|
|
201
|
-
if (/process killed after \d+s timeout/i.test(output)) {
|
|
202
|
-
return { kind: 'timeout', summary };
|
|
203
|
-
}
|
|
204
|
-
return { kind: 'error', summary };
|
|
205
|
-
}
|
|
206
|
-
function summarizeCheckerFailureOutput(output) {
|
|
207
|
-
const lines = output
|
|
208
|
-
.split('\n')
|
|
209
|
-
.map(line => line.trim())
|
|
210
|
-
.filter(Boolean);
|
|
211
|
-
if (lines.length === 0)
|
|
212
|
-
return null;
|
|
213
|
-
const priorityPatterns = [
|
|
214
|
-
/not logged in/i,
|
|
215
|
-
/stream disconnected|error sending request|failed to refresh available models/i,
|
|
216
|
-
/operation not permitted|readonly database|permission denied/i,
|
|
217
|
-
/timeout|timed out/i,
|
|
218
|
-
/unauthorized|forbidden/i,
|
|
219
|
-
/^error:/i,
|
|
220
|
-
/failed/i,
|
|
221
|
-
];
|
|
222
|
-
let summary = lines[lines.length - 1];
|
|
223
|
-
for (const pattern of priorityPatterns) {
|
|
224
|
-
const match = lines.find(line => pattern.test(line));
|
|
225
|
-
if (match) {
|
|
226
|
-
summary = match;
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return summary.length > 220
|
|
231
|
-
? `${summary.slice(0, 217)}...`
|
|
232
|
-
: summary;
|
|
233
|
-
}
|
|
234
|
-
// ============================================================================
|
|
235
|
-
// Result reading
|
|
236
|
-
// ============================================================================
|
|
237
|
-
/**
|
|
238
|
-
* Find the last JSON object in the sub-agent's stdout whose `checker` field
|
|
239
|
-
* names this checker. The sub-agent has no write tool, so its final reply is
|
|
240
|
-
* the result; scanning from the end skips any schema echoes earlier on.
|
|
241
|
-
*/
|
|
242
|
-
export function extractCheckerJson(stdout, checkerName) {
|
|
243
|
-
for (let start = stdout.lastIndexOf('{'); start >= 0; start = start > 0 ? stdout.lastIndexOf('{', start - 1) : -1) {
|
|
244
|
-
const end = findObjectEnd(stdout, start);
|
|
245
|
-
if (end < 0)
|
|
246
|
-
continue;
|
|
247
|
-
const candidate = stdout.slice(start, end + 1);
|
|
248
|
-
let parsed;
|
|
249
|
-
try {
|
|
250
|
-
parsed = JSON.parse(candidate);
|
|
251
|
-
}
|
|
252
|
-
catch {
|
|
253
|
-
// Not a complete JSON object at this brace; keep scanning backwards.
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
if (parsed && typeof parsed === 'object' && parsed.checker === checkerName) {
|
|
257
|
-
return candidate;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
/** Index of the `}` closing the object that opens at `start`, or -1. Respects JSON strings. */
|
|
263
|
-
function findObjectEnd(text, start) {
|
|
264
|
-
let depth = 0;
|
|
265
|
-
let inString = false;
|
|
266
|
-
for (let i = start; i < text.length; i++) {
|
|
267
|
-
const ch = text[i];
|
|
268
|
-
if (inString) {
|
|
269
|
-
if (ch === '\\')
|
|
270
|
-
i++;
|
|
271
|
-
else if (ch === '"')
|
|
272
|
-
inString = false;
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
if (ch === '"')
|
|
276
|
-
inString = true;
|
|
277
|
-
else if (ch === '{')
|
|
278
|
-
depth++;
|
|
279
|
-
else if (ch === '}') {
|
|
280
|
-
depth--;
|
|
281
|
-
if (depth === 0)
|
|
282
|
-
return i;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
return -1;
|
|
286
|
-
}
|
|
287
|
-
/**
|
|
288
|
-
* Validate a checker result JSON string.
|
|
289
|
-
*/
|
|
290
|
-
function parseCheckerResult(content) {
|
|
291
|
-
try {
|
|
292
|
-
const result = JSON.parse(content);
|
|
293
|
-
// Basic validation
|
|
294
|
-
if (!result.checker || !Array.isArray(result.issues) || typeof result.passed !== 'boolean') {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
297
|
-
// Ensure issues have required fields
|
|
298
|
-
result.issues = result.issues.filter((issue) => issue.file && issue.message && issue.severity);
|
|
299
|
-
// Recalculate passed based on issues
|
|
300
|
-
result.passed = !result.issues.some((i) => i.severity === 'error');
|
|
301
|
-
return result;
|
|
302
|
-
}
|
|
303
|
-
catch {
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
83
|
+
const passed = !issues.some(issue => issue.severity === 'error');
|
|
84
|
+
const result = name === 'rules-validator'
|
|
85
|
+
? { checker: name, issues, summary: reply.summary, passed, rulesChecked: reply.rulesChecked ?? 0 }
|
|
86
|
+
: { checker: name, issues, summary: reply.summary, passed };
|
|
87
|
+
return { result, malformed };
|
|
306
88
|
}
|
|
307
89
|
// ============================================================================
|
|
308
90
|
// Main runner
|
|
309
91
|
// ============================================================================
|
|
310
92
|
/**
|
|
311
|
-
* Run pre-PR triage checks
|
|
93
|
+
* Run pre-PR triage checks in parallel.
|
|
312
94
|
*
|
|
313
95
|
* @param gitRoot - Absolute path to the git repository root
|
|
314
96
|
* @param baseBranch - The base branch to diff against (e.g., "main")
|
|
315
97
|
* @returns Aggregated triage results
|
|
316
|
-
* @throws If no agentic CLI is detected
|
|
317
98
|
*/
|
|
318
99
|
export async function runTriage(gitRoot, baseBranch, options = {}) {
|
|
319
100
|
const startTime = Date.now();
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// calling us — we just see a flat override map.
|
|
323
|
-
const maxTurns = { ...DEFAULT_MAX_TURNS };
|
|
324
|
-
for (const [name, value] of Object.entries(options.maxTurns ?? {})) {
|
|
325
|
-
if (typeof value === 'number' && value > 0) {
|
|
326
|
-
maxTurns[name] = value;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
330
|
-
// Detect CLI
|
|
331
|
-
const cli = detectAgenticCLI();
|
|
332
|
-
if (!cli) {
|
|
333
|
-
throw new Error('No agentic CLI found. Install Claude Code (claude) or Codex CLI (codex); Gemini CLI cannot be confined to read-only tools, so triage does not use it.');
|
|
334
|
-
}
|
|
335
|
-
// Refuse runners that cannot be confined to read-only tools.
|
|
336
|
-
if (cli === 'gemini') {
|
|
337
|
-
throw new UnrestrictableTriageCLIError(cli);
|
|
338
|
-
}
|
|
339
|
-
// Fast-fail in Codex app shells where outbound network is disabled.
|
|
340
|
-
// Spawning nested `codex exec` will fail after retries; skip triage immediately instead.
|
|
341
|
-
const codexNetworkDisabled = process.env.CODEX_SANDBOX_NETWORK_DISABLED === '1';
|
|
342
|
-
if (cli === 'codex' && codexNetworkDisabled) {
|
|
343
|
-
throw new Error('Codex shell has network disabled (CODEX_SANDBOX_NETWORK_DISABLED=1), so triage sub-agents cannot run. Re-run outside this sandbox or use --force.');
|
|
344
|
-
}
|
|
345
|
-
console.log(chalk.dim(` Using: ${cli}`));
|
|
101
|
+
const stallMs = options.timeoutMs ?? DEFAULT_STALL_MS;
|
|
102
|
+
console.log(chalk.dim(` Reviewer: ${TRIAGE_MODEL} (reasoning effort ${TRIAGE_REASONING_EFFORT})`));
|
|
346
103
|
// Prepare output directory
|
|
347
104
|
const triageDir = join(gitRoot, TRIAGE_DIR);
|
|
348
105
|
if (existsSync(triageDir)) {
|
|
@@ -351,132 +108,93 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
|
|
|
351
108
|
mkdirSync(triageDir, { recursive: true });
|
|
352
109
|
// Resolve the ref to diff against once. Prefers origin/<base> over the bare
|
|
353
110
|
// local <base> ref (which is often stale and balloons the diff with already-
|
|
354
|
-
// merged code).
|
|
355
|
-
// commands in the prompts use this single resolved ref so they all see the
|
|
356
|
-
// same fork point.
|
|
111
|
+
// merged code).
|
|
357
112
|
const diffBaseRef = resolveDiffBaseRef(baseBranch);
|
|
358
113
|
if (diffBaseRef !== baseBranch) {
|
|
359
114
|
console.log(chalk.dim(` Diff base: ${diffBaseRef}`));
|
|
360
115
|
}
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
let diffOutput;
|
|
116
|
+
// 10 lines of context per hunk: the reviewer sees only the diff. argv, not a
|
|
117
|
+
// shell string: the ref comes from a branch name.
|
|
118
|
+
let diff;
|
|
365
119
|
try {
|
|
366
|
-
|
|
120
|
+
diff = execFileSync('git', ['diff', '-U10', '--end-of-options', `${diffBaseRef}...HEAD`], { cwd: gitRoot, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
367
121
|
}
|
|
368
122
|
catch (err) {
|
|
369
123
|
const stderr = err?.stderr?.toString().trim();
|
|
370
124
|
const cause = stderr || (err instanceof Error ? err.message : String(err));
|
|
371
125
|
throw new Error(`Could not compute the triage diff against ${diffBaseRef}: ${cause}`);
|
|
372
126
|
}
|
|
373
|
-
|
|
374
|
-
// diffs are read from the file in chunks by the agent's read tool.
|
|
375
|
-
const diffPath = join(triageDir, 'diff.patch');
|
|
376
|
-
writeFileSync(diffPath, diffOutput);
|
|
377
|
-
const diff = {
|
|
378
|
-
inline: diffOutput.length <= MAX_INLINE_DIFF_CHARS ? diffOutput : null,
|
|
379
|
-
path: diffPath,
|
|
380
|
-
};
|
|
127
|
+
writeFileSync(join(triageDir, 'diff.patch'), diff);
|
|
381
128
|
// Build checker configs
|
|
382
129
|
const checkers = [];
|
|
383
130
|
// 1. Code review (always runs)
|
|
384
|
-
const codeReviewOutput = join(triageDir, 'code-review.json');
|
|
385
131
|
checkers.push({
|
|
386
132
|
name: 'code-review',
|
|
387
|
-
prompt: buildCodeReviewPrompt(diffBaseRef, diff
|
|
388
|
-
|
|
389
|
-
|
|
133
|
+
prompt: buildCodeReviewPrompt(diffBaseRef, diff),
|
|
134
|
+
schema: CODE_REVIEW_SCHEMA,
|
|
135
|
+
outputFile: join(triageDir, 'code-review.json'),
|
|
390
136
|
});
|
|
391
137
|
// 2. Rules validator (if pr-rules.yml or agent instruction files exist)
|
|
392
138
|
const rulesPath = join(gitRoot, '.haystack', 'pr-rules.yml');
|
|
393
|
-
const
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
prompt: rulesPrompt,
|
|
403
|
-
outputFile: rulesValidatorOutput,
|
|
404
|
-
maxTurns: maxTurns['rules-validator'],
|
|
405
|
-
});
|
|
406
|
-
}
|
|
139
|
+
const rulesYaml = existsSync(rulesPath) ? readFileSync(rulesPath, 'utf-8') : '';
|
|
140
|
+
const rulesPrompt = buildRulesValidatorPrompt(diffBaseRef, rulesYaml, diff, discoverAgentPolicyFiles(gitRoot));
|
|
141
|
+
if (rulesPrompt) {
|
|
142
|
+
checkers.push({
|
|
143
|
+
name: 'rules-validator',
|
|
144
|
+
prompt: rulesPrompt,
|
|
145
|
+
schema: RULES_VALIDATOR_SCHEMA,
|
|
146
|
+
outputFile: join(triageDir, 'rules-validator.json'),
|
|
147
|
+
});
|
|
407
148
|
}
|
|
408
|
-
|
|
409
|
-
const checkerNames = checkers.map(c => c.name);
|
|
410
|
-
console.log(chalk.dim(` Checkers: ${checkerNames.join(', ')}`));
|
|
149
|
+
console.log(chalk.dim(` Checkers: ${checkers.map(c => c.name).join(', ')}`));
|
|
411
150
|
console.log(chalk.dim(` Running ${checkers.length} checker(s) in parallel...\n`));
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
151
|
+
const settled = await Promise.allSettled(checkers.map(async (checker) => {
|
|
152
|
+
const label = chalk.dim(` [${checker.name}]`);
|
|
153
|
+
console.log(`${label} Starting...`);
|
|
154
|
+
try {
|
|
155
|
+
const reply = await callAstra({
|
|
156
|
+
instructions: checker.prompt.instructions,
|
|
157
|
+
input: checker.prompt.input,
|
|
158
|
+
schemaName: checker.name.replace('-', '_'),
|
|
159
|
+
schema: checker.schema,
|
|
160
|
+
stallMs,
|
|
161
|
+
});
|
|
162
|
+
const mapped = toCheckerResult(checker.name, reply);
|
|
163
|
+
writeFileSync(checker.outputFile, JSON.stringify(mapped.result, null, 2));
|
|
164
|
+
console.log(`${label} ${chalk.green('Done')}`);
|
|
165
|
+
return mapped;
|
|
419
166
|
}
|
|
420
|
-
|
|
421
|
-
console.log(`${
|
|
167
|
+
catch (err) {
|
|
168
|
+
console.log(`${label} ${chalk.yellow('Failed')}`);
|
|
169
|
+
throw err;
|
|
422
170
|
}
|
|
423
|
-
return { checker, result };
|
|
424
171
|
}));
|
|
425
|
-
// Collect results
|
|
426
172
|
const results = [];
|
|
427
173
|
const errors = [];
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
checker
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
reason: String(spawnResult.reason),
|
|
437
|
-
});
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
const { checker, result } = spawnResult.value;
|
|
441
|
-
const checkerJson = extractCheckerJson(result.stdout, checker.name);
|
|
442
|
-
const checkerResult = checkerJson ? parseCheckerResult(checkerJson) : null;
|
|
443
|
-
if (checkerJson && checkerResult) {
|
|
444
|
-
// Keep the on-disk result artifact the sub-agent used to write itself.
|
|
445
|
-
writeFileSync(checker.outputFile, checkerJson);
|
|
446
|
-
results.push(checkerResult);
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
// Sub-agent didn't produce valid output — classify as warning vs hard error.
|
|
450
|
-
const classification = classifyCheckerFailure(result.output);
|
|
451
|
-
if (classification.kind === 'max_turns') {
|
|
452
|
-
warnings.push(`${checker.name}: hit max turns (${checker.maxTurns}) — raise via --max-turns or .haystack.json triage.maxTurns, or split the PR into smaller changes`);
|
|
453
|
-
}
|
|
454
|
-
else if (classification.kind === 'timeout') {
|
|
455
|
-
warnings.push(`${checker.name}: wall-clock timeout after ${Math.round(timeoutMs / 1000)}s — consider splitting the PR`);
|
|
456
|
-
}
|
|
457
|
-
else {
|
|
458
|
-
const exitPart = result.exitCode !== 0 ? ` (exit ${result.exitCode})` : '';
|
|
459
|
-
errors.push(`${checker.name}: ${classification.summary}${exitPart}`);
|
|
174
|
+
settled.forEach((outcome, index) => {
|
|
175
|
+
const checker = checkers[index];
|
|
176
|
+
if (outcome.status === 'fulfilled') {
|
|
177
|
+
results.push(outcome.value.result);
|
|
178
|
+
if (outcome.value.malformed > 0) {
|
|
179
|
+
errors.push(`${CHECKER_LABEL[checker.name]} returned ${outcome.value.malformed} finding(s) with an empty file or summary; they were left out`);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
460
182
|
}
|
|
461
|
-
|
|
183
|
+
const message = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
184
|
+
errors.push(`${CHECKER_LABEL[checker.name]} failed: ${message}`);
|
|
462
185
|
trackError('triage_checker_failed', {
|
|
463
186
|
checker: checker.name,
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
timeout_ms: timeoutMs,
|
|
468
|
-
exit_code: result.exitCode,
|
|
469
|
-
summary: classification.summary,
|
|
187
|
+
model: TRIAGE_MODEL,
|
|
188
|
+
stall_ms: stallMs,
|
|
189
|
+
summary: message.slice(0, 220),
|
|
470
190
|
});
|
|
471
|
-
}
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
const passed = results.every(r => r.passed) && errors.length === 0;
|
|
191
|
+
});
|
|
192
|
+
// A checker that failed to run gives no signal either way; only findings
|
|
193
|
+
// with severity "error" fail triage.
|
|
475
194
|
return {
|
|
476
|
-
passed,
|
|
195
|
+
passed: results.every(r => r.passed),
|
|
477
196
|
results,
|
|
478
197
|
errors,
|
|
479
|
-
warnings,
|
|
480
198
|
duration: Date.now() - startTime,
|
|
481
199
|
};
|
|
482
200
|
}
|
package/dist/triage/types.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for the pre-PR triage system.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Each checker is one structured gpt-6-astra call (see astra.ts); the runner
|
|
5
|
+
* maps its findings onto these shapes and aggregates them.
|
|
6
6
|
*/
|
|
7
7
|
export {};
|
package/dist/types.js
CHANGED
|
@@ -271,12 +271,8 @@ const SecretDeclarationSchema = z.object({
|
|
|
271
271
|
// =============================================================================
|
|
272
272
|
// TRIAGE CONFIGURATION
|
|
273
273
|
// =============================================================================
|
|
274
|
-
const TriageCheckerNameSchema = z.enum(['code-review', 'rules-validator']);
|
|
275
274
|
const TriageConfigSchema = z.object({
|
|
276
|
-
/**
|
|
277
|
-
* partialRecord, because zod 4's z.record() with enum keys requires every key. */
|
|
278
|
-
maxTurns: z.partialRecord(TriageCheckerNameSchema, z.number().int().positive()).optional(),
|
|
279
|
-
/** Wall-clock timeout per checker, in milliseconds. */
|
|
275
|
+
/** Abort a checker when its model stream has been silent this long, in milliseconds. */
|
|
280
276
|
timeoutMs: z.number().int().positive().optional(),
|
|
281
277
|
});
|
|
282
278
|
// =============================================================================
|
|
@@ -322,6 +318,6 @@ export const HaystackConfigSchema = z.object({
|
|
|
322
318
|
secrets: z.record(z.string(), SecretDeclarationSchema).optional(),
|
|
323
319
|
/** Merge queue configuration for autonomous PR maintenance */
|
|
324
320
|
merge_queue: MergeQueueConfigSchema.optional(),
|
|
325
|
-
/** Pre-PR triage configuration (
|
|
321
|
+
/** Pre-PR triage configuration (stall timeout) */
|
|
326
322
|
triage: TriageConfigSchema.optional(),
|
|
327
323
|
});
|
|
@@ -39,6 +39,12 @@ function headers(token, extra) {
|
|
|
39
39
|
*/
|
|
40
40
|
export const SUBSCRIPTION_REQUIRED_CODE = 'subscription_required';
|
|
41
41
|
export const SUBSCRIPTION_REQUIRED_MESSAGE = 'This account has no active Haystack subscription. See https://haystack.sh/pricing or email akshay@haystack.sh to get access.';
|
|
42
|
+
/**
|
|
43
|
+
* The server returns this `error` code (HTTP 403) when the account is not
|
|
44
|
+
* entitled to the repository a request addresses (entitlement is per
|
|
45
|
+
* repository and team). Its message names the repository; print it as is.
|
|
46
|
+
*/
|
|
47
|
+
export const REPO_NOT_ENTITLED_CODE = 'repo_not_entitled';
|
|
42
48
|
/**
|
|
43
49
|
* Extract the server's own message (and its `error` code) if the body is
|
|
44
50
|
* JSON; truncate raw bodies.
|
|
@@ -78,6 +84,12 @@ export async function classifyHttpError(response, context) {
|
|
|
78
84
|
if (response.status === 403 && code === SUBSCRIPTION_REQUIRED_CODE) {
|
|
79
85
|
return new HaystackApiError(403, SUBSCRIPTION_REQUIRED_MESSAGE, code);
|
|
80
86
|
}
|
|
87
|
+
if (response.status === 403 && code === REPO_NOT_ENTITLED_CODE) {
|
|
88
|
+
const message = detail && detail !== code
|
|
89
|
+
? detail.replace(/\s+/g, ' ').trim()
|
|
90
|
+
: 'This account is not entitled to use Haystack on this repository. See https://haystack.sh/pricing or email akshay@haystack.sh to get access.';
|
|
91
|
+
return new HaystackApiError(403, message, code);
|
|
92
|
+
}
|
|
81
93
|
if (response.status === 401) {
|
|
82
94
|
return new HaystackApiError(401, `${context}: authentication failed${suffix}. Run \`haystack login\` to re-authenticate.`);
|
|
83
95
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haystackeditor/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"url": "https://github.com/haystackeditor/haystack-review/issues"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"@aws-sdk/client-ssm": "3.987.0",
|
|
43
44
|
"@babel/core": "8.0.6",
|
|
44
45
|
"@inquirer/prompts": "8.7.2",
|
|
45
46
|
"@modelcontextprotocol/sdk": "1.26.0",
|