@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.
@@ -1,39 +1,30 @@
1
1
  /**
2
- * Triage runner — orchestrates parallel sub-agent execution for pre-PR checks.
2
+ * Triage runner — runs the pre-PR checkers in parallel and aggregates results.
3
3
  *
4
- * Detects the user's agentic CLI (Claude Code, Codex, Gemini), spawns
5
- * parallel sub-processes for each checker, and collects structured JSON results.
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, spawn } from 'child_process';
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
- * Read-only tool set for the Claude sub-agent. The prompt carries
22
- * repo-controlled text (pr-rules.yml, CLAUDE.md, the diff), so the agent must
23
- * not be able to run commands or write files.
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 CLAUDE_READ_ONLY_TOOLS = 'Read,Grep,Glob';
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
- // CLI detection
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
- * Spawn a sub-agent process and wait for it to complete.
146
- * Returns the exit code.
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 spawnChecker(cli, config, cwd, timeoutMs) {
149
- return new Promise((resolve) => {
150
- const { command, args } = buildCommand(cli, config.prompt, config.maxTurns);
151
- let stdout = '';
152
- let stderr = '';
153
- const proc = spawn(command, args, {
154
- cwd,
155
- stdio: ['ignore', 'pipe', 'pipe'],
156
- env: {
157
- ...process.env,
158
- CLAUDECODE: undefined,
159
- },
160
- });
161
- proc.stdout?.on('data', (data) => {
162
- stdout += data.toString();
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
- * Classify why a checker failed to produce a valid JSON result.
184
- *
185
- * - `max_turns`: the sub-agent exhausted its --max-turns budget. Expected when
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 using parallel sub-agent processes.
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
- // Merge defaults with caller-provided overrides. The caller (submit.ts) is
321
- // responsible for layering CLI flags on top of .haystack.json values before
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). Every git read below — the precomputed diff, the agent diff
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
- // Compute the diff here: sub-agents have no command tool, so they cannot run
362
- // git diff themselves. Includes 10 lines of context around each hunk for
363
- // reviewability. argv, not a shell string: the ref comes from a branch name.
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
- diffOutput = execFileSync('git', ['diff', '-U10', '--end-of-options', `${diffBaseRef}...HEAD`], { cwd: gitRoot, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
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
- // Always write the diff to a file; inline it only when small enough. Larger
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, maxTurns['code-review'], timeoutMs),
388
- outputFile: codeReviewOutput,
389
- maxTurns: maxTurns['code-review'],
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 hasRulesYaml = existsSync(rulesPath);
394
- const rulesYaml = hasRulesYaml ? readFileSync(rulesPath, 'utf-8') : '';
395
- const agentPolicyFiles = discoverAgentPolicyFiles(gitRoot);
396
- if (hasRulesYaml || agentPolicyFiles.length > 0) {
397
- const rulesValidatorOutput = join(triageDir, 'rules-validator.json');
398
- const rulesPrompt = buildRulesValidatorPrompt(diffBaseRef, rulesYaml, diff, maxTurns['rules-validator'], timeoutMs, agentPolicyFiles);
399
- if (rulesPrompt) {
400
- checkers.push({
401
- name: 'rules-validator',
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
- // Log what we're running
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
- // Spawn all checkers in parallel
413
- const spawnResults = await Promise.allSettled(checkers.map(async (checker) => {
414
- const startLabel = chalk.dim(` [${checker.name}]`);
415
- console.log(`${startLabel} Starting...`);
416
- const result = await spawnChecker(cli, checker, gitRoot, timeoutMs);
417
- if (result.exitCode === 0) {
418
- console.log(`${startLabel} ${chalk.green('Done')}`);
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
- else {
421
- console.log(`${startLabel} ${chalk.yellow(`Exited with code ${result.exitCode}`)}`);
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
- const warnings = [];
429
- for (const spawnResult of spawnResults) {
430
- if (spawnResult.status === 'rejected') {
431
- errors.push(`Checker failed: ${spawnResult.reason}`);
432
- trackError('triage_checker_failed', {
433
- checker: 'unknown',
434
- kind: 'spawn_rejected',
435
- cli,
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
- // Emit telemetry for every failed checker so we can tune defaults empirically.
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
- kind: classification.kind,
465
- cli,
466
- max_turns: checker.maxTurns,
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
- // Warnings are non-blocking by design — they're expected outcomes of the
473
- // turn/time caps, not signs of real problems.
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
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Types for the pre-PR triage system.
3
3
  *
4
- * Sub-agents write results as JSON files matching these schemas.
5
- * The runner reads and aggregates them.
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
- /** Per-checker max agentic turns. Unspecified checkers use built-in defaults.
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 (turn budgets, timeouts) */
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.16.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",