@haystackeditor/cli 0.12.3 → 0.13.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.
@@ -10,14 +10,20 @@ import { join } from 'path';
10
10
  import chalk from 'chalk';
11
11
  import { buildCodeReviewPrompt, buildRulesValidatorPrompt, buildIntentDriftPrompt } from './prompts.js';
12
12
  import { findRelevantTraces } from './traces.js';
13
+ import { trackError } from '../utils/telemetry.js';
13
14
  // ============================================================================
14
15
  // Constants
15
16
  // ============================================================================
16
17
  const TRIAGE_DIR = '.haystack/triage';
17
- const TIMEOUT_MS = 300_000; // 5 minutes per checker
18
- /** Max agentic turns per checker — kept tight since these are read-only tasks */
19
- const MAX_TURNS = {
20
- 'code-review': 5,
18
+ /** Wall-clock timeout per checker. Overridable via .haystack.json triage.timeoutMs or --triage-timeout. */
19
+ const DEFAULT_TIMEOUT_MS = 180_000; // 3 minutes
20
+ /**
21
+ * Default max agentic turns per checker. Overridable via .haystack.json
22
+ * triage.maxTurns or --max-turns. code-review tends to saturate first because
23
+ * it reads the diff line-by-line; 8 is the breakeven we landed on empirically.
24
+ */
25
+ const DEFAULT_MAX_TURNS = {
26
+ 'code-review': 8,
21
27
  'rules-validator': 10,
22
28
  'intent-drift': 10,
23
29
  };
@@ -107,7 +113,7 @@ function buildCommand(cli, prompt, maxTurns) {
107
113
  * Spawn a sub-agent process and wait for it to complete.
108
114
  * Returns the exit code.
109
115
  */
110
- function spawnChecker(cli, config, cwd) {
116
+ function spawnChecker(cli, config, cwd, timeoutMs) {
111
117
  return new Promise((resolve) => {
112
118
  const { command, args } = buildCommand(cli, config.prompt, config.maxTurns);
113
119
  let stdout = '';
@@ -134,8 +140,8 @@ function spawnChecker(cli, config, cwd) {
134
140
  // Timeout
135
141
  const timer = setTimeout(() => {
136
142
  proc.kill('SIGTERM');
137
- stderr += `\nProcess killed after ${TIMEOUT_MS / 1000}s timeout`;
138
- }, TIMEOUT_MS);
143
+ stderr += `\nProcess killed after ${Math.round(timeoutMs / 1000)}s timeout`;
144
+ }, timeoutMs);
139
145
  proc.on('close', (code) => {
140
146
  clearTimeout(timer);
141
147
  resolve({ exitCode: code ?? 1, output: `${stdout}\n${stderr}`.trim() });
@@ -146,6 +152,30 @@ function spawnChecker(cli, config, cwd) {
146
152
  });
147
153
  });
148
154
  }
155
+ /**
156
+ * Classify why a checker failed to produce a valid JSON result.
157
+ *
158
+ * - `max_turns`: the sub-agent exhausted its --max-turns budget. Expected when
159
+ * the diff is too large for the budget; should not block submit.
160
+ * - `timeout`: we killed the process at the configured wall-clock cap. Also non-blocking.
161
+ * - `error`: anything else (auth failure, crash, malformed output). Blocks.
162
+ *
163
+ * The max-turns markers come from the CLIs themselves (Claude CLI prints
164
+ * "Reached max turns", Codex prints "turn limit"). If a new CLI is added, add
165
+ * its marker here — unknown failures fall through to `error` (safe default).
166
+ */
167
+ function classifyCheckerFailure(output) {
168
+ const summary = summarizeCheckerFailureOutput(output) ?? '(no output)';
169
+ // Claude: "Reached max turns (N)". Codex/Gemini variants: "turn limit", "max iterations".
170
+ if (/reached max turns|max turns reached|turn limit|max iterations/i.test(output)) {
171
+ return { kind: 'max_turns', summary };
172
+ }
173
+ // Our own SIGTERM message from spawnChecker.
174
+ if (/process killed after \d+s timeout/i.test(output)) {
175
+ return { kind: 'timeout', summary };
176
+ }
177
+ return { kind: 'error', summary };
178
+ }
149
179
  function summarizeCheckerFailureOutput(output) {
150
180
  const lines = output
151
181
  .split('\n')
@@ -212,8 +242,18 @@ function readCheckerResult(outputPath, checkerName) {
212
242
  * @returns Aggregated triage results
213
243
  * @throws If no agentic CLI is detected
214
244
  */
215
- export async function runTriage(gitRoot, baseBranch) {
245
+ export async function runTriage(gitRoot, baseBranch, options = {}) {
216
246
  const startTime = Date.now();
247
+ // Merge defaults with caller-provided overrides. The caller (submit.ts) is
248
+ // responsible for layering CLI flags on top of .haystack.json values before
249
+ // calling us — we just see a flat override map.
250
+ const maxTurns = { ...DEFAULT_MAX_TURNS };
251
+ for (const [name, value] of Object.entries(options.maxTurns ?? {})) {
252
+ if (typeof value === 'number' && value > 0) {
253
+ maxTurns[name] = value;
254
+ }
255
+ }
256
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
217
257
  // Detect CLI
218
258
  const cli = detectAgenticCLI();
219
259
  if (!cli) {
@@ -252,9 +292,9 @@ export async function runTriage(gitRoot, baseBranch) {
252
292
  const codeReviewOutput = join(triageDir, 'code-review.json');
253
293
  checkers.push({
254
294
  name: 'code-review',
255
- prompt: buildCodeReviewPrompt(baseBranch, codeReviewOutput, precomputedDiff),
295
+ prompt: buildCodeReviewPrompt(baseBranch, codeReviewOutput, maxTurns['code-review'], timeoutMs, precomputedDiff),
256
296
  outputFile: codeReviewOutput,
257
- maxTurns: MAX_TURNS['code-review'],
297
+ maxTurns: maxTurns['code-review'],
258
298
  });
259
299
  // 2. Rules validator (if pr-rules.yml or agent instruction files exist)
260
300
  const rulesPath = join(gitRoot, '.haystack', 'pr-rules.yml');
@@ -263,13 +303,13 @@ export async function runTriage(gitRoot, baseBranch) {
263
303
  const agentPolicyFiles = discoverAgentPolicyFiles(gitRoot);
264
304
  if (hasRulesYaml || agentPolicyFiles.length > 0) {
265
305
  const rulesValidatorOutput = join(triageDir, 'rules-validator.json');
266
- const rulesPrompt = buildRulesValidatorPrompt(baseBranch, rulesYaml, rulesValidatorOutput, agentPolicyFiles, precomputedDiff);
306
+ const rulesPrompt = buildRulesValidatorPrompt(baseBranch, rulesYaml, rulesValidatorOutput, maxTurns['rules-validator'], timeoutMs, agentPolicyFiles, precomputedDiff);
267
307
  if (rulesPrompt) {
268
308
  checkers.push({
269
309
  name: 'rules-validator',
270
310
  prompt: rulesPrompt,
271
311
  outputFile: rulesValidatorOutput,
272
- maxTurns: MAX_TURNS['rules-validator'],
312
+ maxTurns: maxTurns['rules-validator'],
273
313
  });
274
314
  }
275
315
  }
@@ -277,13 +317,13 @@ export async function runTriage(gitRoot, baseBranch) {
277
317
  const traceFiles = findRelevantTraces(gitRoot, baseBranch);
278
318
  if (traceFiles.length > 0) {
279
319
  const intentDriftOutput = join(triageDir, 'intent-drift.json');
280
- const driftPrompt = buildIntentDriftPrompt(baseBranch, traceFiles, intentDriftOutput, precomputedDiff);
320
+ const driftPrompt = buildIntentDriftPrompt(baseBranch, traceFiles, intentDriftOutput, maxTurns['intent-drift'], timeoutMs, precomputedDiff);
281
321
  if (driftPrompt) {
282
322
  checkers.push({
283
323
  name: 'intent-drift',
284
324
  prompt: driftPrompt,
285
325
  outputFile: intentDriftOutput,
286
- maxTurns: MAX_TURNS['intent-drift'],
326
+ maxTurns: maxTurns['intent-drift'],
287
327
  });
288
328
  }
289
329
  }
@@ -298,7 +338,7 @@ export async function runTriage(gitRoot, baseBranch) {
298
338
  const spawnResults = await Promise.allSettled(checkers.map(async (checker) => {
299
339
  const startLabel = chalk.dim(` [${checker.name}]`);
300
340
  console.log(`${startLabel} Starting...`);
301
- const result = await spawnChecker(cli, checker, gitRoot);
341
+ const result = await spawnChecker(cli, checker, gitRoot, timeoutMs);
302
342
  if (result.exitCode === 0) {
303
343
  console.log(`${startLabel} ${chalk.green('Done')}`);
304
344
  }
@@ -310,30 +350,55 @@ export async function runTriage(gitRoot, baseBranch) {
310
350
  // Collect results
311
351
  const results = [];
312
352
  const errors = [];
353
+ const warnings = [];
313
354
  for (const spawnResult of spawnResults) {
314
355
  if (spawnResult.status === 'rejected') {
315
356
  errors.push(`Checker failed: ${spawnResult.reason}`);
357
+ trackError('triage_checker_failed', {
358
+ checker: 'unknown',
359
+ kind: 'spawn_rejected',
360
+ cli,
361
+ reason: String(spawnResult.reason),
362
+ });
316
363
  continue;
317
364
  }
318
365
  const { checker, result } = spawnResult.value;
319
366
  const checkerResult = readCheckerResult(checker.outputFile, checker.name);
320
367
  if (checkerResult) {
321
368
  results.push(checkerResult);
369
+ continue;
370
+ }
371
+ // Sub-agent didn't produce valid output — classify as warning vs hard error.
372
+ const classification = classifyCheckerFailure(result.output);
373
+ if (classification.kind === 'max_turns') {
374
+ 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`);
375
+ }
376
+ else if (classification.kind === 'timeout') {
377
+ warnings.push(`${checker.name}: wall-clock timeout after ${Math.round(timeoutMs / 1000)}s — consider splitting the PR`);
322
378
  }
323
379
  else {
324
- // Sub-agent didn't produce valid output
325
- const failureSummary = summarizeCheckerFailureOutput(result.output);
326
- const errMsg = result.exitCode !== 0
327
- ? `${checker.name}: process exited with code ${result.exitCode}${failureSummary ? ` (${failureSummary})` : ''}`
328
- : `${checker.name}: no valid result file produced`;
329
- errors.push(errMsg);
380
+ const exitPart = result.exitCode !== 0 ? ` (exit ${result.exitCode})` : '';
381
+ errors.push(`${checker.name}: ${classification.summary}${exitPart}`);
330
382
  }
383
+ // Emit telemetry for every failed checker so we can tune defaults empirically.
384
+ trackError('triage_checker_failed', {
385
+ checker: checker.name,
386
+ kind: classification.kind,
387
+ cli,
388
+ max_turns: checker.maxTurns,
389
+ timeout_ms: timeoutMs,
390
+ exit_code: result.exitCode,
391
+ summary: classification.summary,
392
+ });
331
393
  }
394
+ // Warnings are non-blocking by design — they're expected outcomes of the
395
+ // turn/time caps, not signs of real problems.
332
396
  const passed = results.every(r => r.passed) && errors.length === 0;
333
397
  return {
334
398
  passed,
335
399
  results,
336
400
  errors,
401
+ warnings,
337
402
  duration: Date.now() - startTime,
338
403
  };
339
404
  }
@@ -39,8 +39,10 @@ export type CheckerResult = CodeReviewResult | RulesValidatorResult | IntentDrif
39
39
  export interface TriageResult {
40
40
  passed: boolean;
41
41
  results: CheckerResult[];
42
- /** Errors from sub-agent crashes or missing output */
42
+ /** Hard errors — auth failures, crashed sub-agents, unknown failures. Block submit. */
43
43
  errors: string[];
44
+ /** Soft failures — sub-agent hit max turns or wall-clock timeout. Do NOT block submit. */
45
+ warnings: string[];
44
46
  /** Total duration in ms */
45
47
  duration: number;
46
48
  }
package/dist/types.d.ts CHANGED
@@ -702,6 +702,18 @@ declare const SecretDeclarationSchema: z.ZodObject<{
702
702
  services?: string[] | undefined;
703
703
  description?: string | undefined;
704
704
  }>;
705
+ declare const TriageConfigSchema: z.ZodObject<{
706
+ /** Per-checker max agentic turns. Unspecified checkers use built-in defaults. */
707
+ maxTurns: z.ZodOptional<z.ZodRecord<z.ZodEnum<["code-review", "rules-validator", "intent-drift"]>, z.ZodNumber>>;
708
+ /** Wall-clock timeout per checker, in milliseconds. */
709
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
710
+ }, "strip", z.ZodTypeAny, {
711
+ maxTurns?: Partial<Record<"code-review" | "rules-validator" | "intent-drift", number>> | undefined;
712
+ timeoutMs?: number | undefined;
713
+ }, {
714
+ maxTurns?: Partial<Record<"code-review" | "rules-validator" | "intent-drift", number>> | undefined;
715
+ timeoutMs?: number | undefined;
716
+ }>;
705
717
  export declare const HaystackConfigSchema: z.ZodObject<{
706
718
  /** Config version (must be "1") */
707
719
  version: z.ZodLiteral<"1">;
@@ -1249,6 +1261,19 @@ export declare const HaystackConfigSchema: z.ZodObject<{
1249
1261
  timeout_minutes?: number | undefined;
1250
1262
  } | undefined;
1251
1263
  }>>;
1264
+ /** Pre-PR triage configuration (turn budgets, timeouts) */
1265
+ triage: z.ZodOptional<z.ZodObject<{
1266
+ /** Per-checker max agentic turns. Unspecified checkers use built-in defaults. */
1267
+ maxTurns: z.ZodOptional<z.ZodRecord<z.ZodEnum<["code-review", "rules-validator", "intent-drift"]>, z.ZodNumber>>;
1268
+ /** Wall-clock timeout per checker, in milliseconds. */
1269
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
1270
+ }, "strip", z.ZodTypeAny, {
1271
+ maxTurns?: Partial<Record<"code-review" | "rules-validator" | "intent-drift", number>> | undefined;
1272
+ timeoutMs?: number | undefined;
1273
+ }, {
1274
+ maxTurns?: Partial<Record<"code-review" | "rules-validator" | "intent-drift", number>> | undefined;
1275
+ timeoutMs?: number | undefined;
1276
+ }>>;
1252
1277
  }, "strip", z.ZodTypeAny, {
1253
1278
  version: "1";
1254
1279
  name?: string | undefined;
@@ -1371,6 +1396,10 @@ export declare const HaystackConfigSchema: z.ZodObject<{
1371
1396
  services?: string[] | undefined;
1372
1397
  description?: string | undefined;
1373
1398
  }> | undefined;
1399
+ triage?: {
1400
+ maxTurns?: Partial<Record<"code-review" | "rules-validator" | "intent-drift", number>> | undefined;
1401
+ timeoutMs?: number | undefined;
1402
+ } | undefined;
1374
1403
  }, {
1375
1404
  version: "1";
1376
1405
  name?: string | undefined;
@@ -1493,6 +1522,10 @@ export declare const HaystackConfigSchema: z.ZodObject<{
1493
1522
  services?: string[] | undefined;
1494
1523
  description?: string | undefined;
1495
1524
  }> | undefined;
1525
+ triage?: {
1526
+ maxTurns?: Partial<Record<"code-review" | "rules-validator" | "intent-drift", number>> | undefined;
1527
+ timeoutMs?: number | undefined;
1528
+ } | undefined;
1496
1529
  }>;
1497
1530
  export type HaystackConfig = z.infer<typeof HaystackConfigSchema>;
1498
1531
  export type VerificationCommand = z.infer<typeof VerificationCommandSchema>;
@@ -1513,6 +1546,7 @@ export type DatabaseConfig = z.infer<typeof DatabaseConfigSchema>;
1513
1546
  export type NetworkConfig = z.infer<typeof NetworkConfigSchema>;
1514
1547
  export type SecretDeclaration = z.infer<typeof SecretDeclarationSchema>;
1515
1548
  export type MergeQueueConfig = z.infer<typeof MergeQueueConfigSchema>;
1549
+ export type TriageConfig = z.infer<typeof TriageConfigSchema>;
1516
1550
  /**
1517
1551
  * Project detection results
1518
1552
  */
package/dist/types.js CHANGED
@@ -267,6 +267,16 @@ const SecretDeclarationSchema = z.object({
267
267
  services: z.array(z.string()).optional(),
268
268
  });
269
269
  // =============================================================================
270
+ // TRIAGE CONFIGURATION
271
+ // =============================================================================
272
+ const TriageCheckerNameSchema = z.enum(['code-review', 'rules-validator', 'intent-drift']);
273
+ const TriageConfigSchema = z.object({
274
+ /** Per-checker max agentic turns. Unspecified checkers use built-in defaults. */
275
+ maxTurns: z.record(TriageCheckerNameSchema, z.number().int().positive()).optional(),
276
+ /** Wall-clock timeout per checker, in milliseconds. */
277
+ timeoutMs: z.number().int().positive().optional(),
278
+ });
279
+ // =============================================================================
270
280
  // MAIN CONFIG
271
281
  // =============================================================================
272
282
  export const HaystackConfigSchema = z.object({
@@ -294,4 +304,6 @@ export const HaystackConfigSchema = z.object({
294
304
  secrets: z.record(z.string(), SecretDeclarationSchema).optional(),
295
305
  /** Merge queue configuration for autonomous PR maintenance */
296
306
  merge_queue: MergeQueueConfigSchema.optional(),
307
+ /** Pre-PR triage configuration (turn budgets, timeouts) */
308
+ triage: TriageConfigSchema.optional(),
297
309
  });
@@ -0,0 +1,50 @@
1
+ export interface StoredAccount {
2
+ github_token: string;
3
+ created_at: string;
4
+ }
5
+ export interface CredentialsStore {
6
+ version: 2;
7
+ activeLogin: string | null;
8
+ accounts: Record<string, StoredAccount>;
9
+ repoDefaults: Record<string, string>;
10
+ }
11
+ export interface GitHubUser {
12
+ login: string;
13
+ id: number;
14
+ }
15
+ export type VerifyGitHubTokenResult = {
16
+ status: 'ok';
17
+ user: GitHubUser;
18
+ } | {
19
+ status: 'invalid';
20
+ } | {
21
+ status: 'error';
22
+ message: string;
23
+ };
24
+ export interface ResolveAuthOptions {
25
+ preferredLogin?: string;
26
+ owner?: string;
27
+ repo?: string;
28
+ }
29
+ export interface ResolvedAuthContext {
30
+ login: string;
31
+ token: string;
32
+ source: 'preferred' | 'repo-default' | 'active';
33
+ }
34
+ export interface AccountSummary {
35
+ login: string;
36
+ createdAt: string;
37
+ isActive: boolean;
38
+ repoDefaultCount: number;
39
+ }
40
+ export declare function verifyGitHubToken(token: string): Promise<VerifyGitHubTokenResult>;
41
+ export declare function loadCredentialsStore(): Promise<CredentialsStore>;
42
+ export declare function saveAuthenticatedToken(token: string): Promise<GitHubUser>;
43
+ export declare function listStoredAccounts(): Promise<AccountSummary[]>;
44
+ export declare function setActiveAccount(login: string): Promise<void>;
45
+ export declare function removeAccount(login?: string): Promise<string | null>;
46
+ export declare function getActiveLogin(): Promise<string | null>;
47
+ export declare function getRepoDefaultAccount(owner: string, repo: string): Promise<string | null>;
48
+ export declare function setRepoDefaultAccount(owner: string, repo: string, login: string): Promise<void>;
49
+ export declare function resolveAuthContext(options?: ResolveAuthOptions): Promise<ResolvedAuthContext>;
50
+ export declare function loadToken(options?: ResolveAuthOptions): Promise<string | null>;