@haystackeditor/cli 0.12.2 → 0.13.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.
- package/README.md +1 -1
- package/dist/commands/submit.d.ts +2 -0
- package/dist/commands/submit.js +68 -7
- package/dist/index.js +21 -1
- package/dist/triage/prompts.d.ts +3 -3
- package/dist/triage/prompts.js +24 -6
- package/dist/triage/runner.d.ts +7 -1
- package/dist/triage/runner.js +86 -21
- package/dist/triage/types.d.ts +3 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.js +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -241,7 +241,7 @@ The `setup` wizard writes `.haystack.json` to your repos with discovered rules,
|
|
|
241
241
|
## How It Works
|
|
242
242
|
|
|
243
243
|
1. Run `haystack setup` to configure your repos (or `haystack init` for local-only config)
|
|
244
|
-
2. Install the [Haystack GitHub App](https://
|
|
244
|
+
2. Install the [Haystack GitHub App](https://github.com/apps/haystack-code-reviewer-pr-hook/installations/new)
|
|
245
245
|
3. When PRs are opened, Haystack automatically:
|
|
246
246
|
- Analyzes the code for bugs, instruction drift, and rule violations
|
|
247
247
|
- Reports results on the PR
|
package/dist/commands/submit.js
CHANGED
|
@@ -14,6 +14,7 @@ import fs from 'fs';
|
|
|
14
14
|
import { findGitRoot, getCurrentBranch, getDefaultBranch, hasUncommittedChanges, parseRemoteUrl, pushBranch, getLastCommitMessage, getCommitMessagesSinceBase, } from '../utils/git.js';
|
|
15
15
|
import { createPullRequest, findExistingPR, requireGitHubAuth, requestReviewers, } from '../utils/github-api.js';
|
|
16
16
|
import { runTriage } from '../triage/runner.js';
|
|
17
|
+
import { loadConfig } from '../utils/config.js';
|
|
17
18
|
import { checkAnalysisReady, fetchAnalysisResults } from '../utils/analysis-api.js';
|
|
18
19
|
import { savePendingSubmit, updatePendingSubmit } from '../utils/pending-state.js';
|
|
19
20
|
import { ensureHaystackLabels, addLabelsToIssue, } from '../utils/github-api.js';
|
|
@@ -54,6 +55,47 @@ function checkerDisplayName(checker) {
|
|
|
54
55
|
default: return checker;
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Merge triage-tuning knobs from two sources, in ascending precedence:
|
|
60
|
+
* defaults (in runner.ts) < .haystack.json triage.* < CLI flags
|
|
61
|
+
*
|
|
62
|
+
* --max-turns <N> applies the same number to every checker. Per-checker
|
|
63
|
+
* granularity stays in .haystack.json (less common, more verbose).
|
|
64
|
+
*/
|
|
65
|
+
async function resolveTriageOptions(options) {
|
|
66
|
+
let config = null;
|
|
67
|
+
try {
|
|
68
|
+
config = await loadConfig();
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
// loadConfig returns null when the file doesn't exist — it only throws on
|
|
72
|
+
// read/parse failures, which we must surface so a malformed config doesn't
|
|
73
|
+
// silently lose the user's project-level triage overrides.
|
|
74
|
+
console.warn(chalk.yellow(`⚠ Could not load .haystack.json: ${err.message}`));
|
|
75
|
+
console.warn(chalk.dim(' Triage will use CLI flags / defaults — fix the config to apply project overrides.'));
|
|
76
|
+
}
|
|
77
|
+
const triageConfig = config?.triage ?? {};
|
|
78
|
+
const maxTurns = { ...(triageConfig.maxTurns ?? {}) };
|
|
79
|
+
if (typeof options.maxTurns === 'number' && options.maxTurns > 0) {
|
|
80
|
+
for (const name of ['code-review', 'rules-validator', 'intent-drift']) {
|
|
81
|
+
maxTurns[name] = options.maxTurns;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// loadConfig does a raw `as HaystackConfig` cast (no Zod runtime parse), so
|
|
85
|
+
// malformed .haystack.json values can reach us as strings/negatives/NaN.
|
|
86
|
+
// Mirror the positive-number guard we already apply to maxTurns before
|
|
87
|
+
// letting this flow into setTimeout.
|
|
88
|
+
const configTimeoutMs = typeof triageConfig.timeoutMs === 'number' && triageConfig.timeoutMs > 0
|
|
89
|
+
? triageConfig.timeoutMs
|
|
90
|
+
: undefined;
|
|
91
|
+
const timeoutMs = typeof options.triageTimeout === 'number' && options.triageTimeout > 0
|
|
92
|
+
? options.triageTimeout * 1000
|
|
93
|
+
: configTimeoutMs;
|
|
94
|
+
return {
|
|
95
|
+
maxTurns: Object.keys(maxTurns).length > 0 ? maxTurns : undefined,
|
|
96
|
+
timeoutMs,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
57
99
|
function isCodexNetworkDisabledTriageError(err) {
|
|
58
100
|
const message = err instanceof Error ? err.message : String(err);
|
|
59
101
|
return message.includes('CODEX_SANDBOX_NETWORK_DISABLED=1');
|
|
@@ -80,11 +122,19 @@ function printTriageSummary(result) {
|
|
|
80
122
|
console.log('');
|
|
81
123
|
}
|
|
82
124
|
}
|
|
83
|
-
//
|
|
125
|
+
// Hard errors from sub-agent failures (auth, crashes) — these block submit.
|
|
84
126
|
if (result.errors.length > 0) {
|
|
85
|
-
console.log(chalk.
|
|
127
|
+
console.log(chalk.red(' Checker errors:'));
|
|
86
128
|
for (const err of result.errors) {
|
|
87
|
-
console.log(chalk.
|
|
129
|
+
console.log(chalk.red(` ✗ ${err}`));
|
|
130
|
+
}
|
|
131
|
+
console.log('');
|
|
132
|
+
}
|
|
133
|
+
// Soft failures (max turns, timeout) — non-blocking, purely informational.
|
|
134
|
+
if (result.warnings.length > 0) {
|
|
135
|
+
console.log(chalk.yellow(' Checker warnings (non-blocking):'));
|
|
136
|
+
for (const warn of result.warnings) {
|
|
137
|
+
console.log(chalk.yellow(` ! ${warn}`));
|
|
88
138
|
}
|
|
89
139
|
console.log('');
|
|
90
140
|
}
|
|
@@ -93,9 +143,14 @@ function printTriageSummary(result) {
|
|
|
93
143
|
const errorCount = result.results.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === 'error').length, 0);
|
|
94
144
|
const warningCount = result.results.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === 'warning').length, 0);
|
|
95
145
|
const duration = (result.duration / 1000).toFixed(1);
|
|
96
|
-
if (result.passed) {
|
|
146
|
+
if (result.passed && result.results.length > 0) {
|
|
97
147
|
console.log(chalk.green(` All checks passed (${duration}s)\n`));
|
|
98
148
|
}
|
|
149
|
+
else if (result.passed) {
|
|
150
|
+
// No blocking issues, but nothing actually completed either — the
|
|
151
|
+
// triage-unavailable warning will print separately.
|
|
152
|
+
console.log(chalk.yellow(` No checkers produced results (${duration}s)\n`));
|
|
153
|
+
}
|
|
99
154
|
else {
|
|
100
155
|
const parts = [];
|
|
101
156
|
if (errorCount > 0)
|
|
@@ -162,11 +217,17 @@ export async function submitCommand(options) {
|
|
|
162
217
|
if (!options.force) {
|
|
163
218
|
console.log(chalk.dim('\nRunning pre-PR triage checks...'));
|
|
164
219
|
try {
|
|
165
|
-
const
|
|
220
|
+
const triageOptions = await resolveTriageOptions(options);
|
|
221
|
+
const triageResult = await runTriage(gitRoot, baseBranch, triageOptions);
|
|
166
222
|
printTriageSummary(triageResult);
|
|
167
|
-
|
|
223
|
+
// No valid results means we got zero signal from triage. That's
|
|
224
|
+
// "unavailable" whether the cause was a hard error (auth crash) or a
|
|
225
|
+
// soft cap (every checker hit max turns). Either way don't pretend it
|
|
226
|
+
// passed — print the warning and continue.
|
|
227
|
+
const triageInfraFailure = triageResult.results.length === 0 &&
|
|
228
|
+
(triageResult.errors.length > 0 || triageResult.warnings.length > 0);
|
|
168
229
|
if (triageInfraFailure) {
|
|
169
|
-
console.log(chalk.yellow('\n⚠ Triage unavailable (
|
|
230
|
+
console.log(chalk.yellow('\n⚠ Triage unavailable (no checkers produced results). Continuing with submit.\n'));
|
|
170
231
|
}
|
|
171
232
|
if (!triageResult.passed) {
|
|
172
233
|
if (triageInfraFailure) {
|
package/dist/index.js
CHANGED
|
@@ -38,7 +38,7 @@ const program = new Command();
|
|
|
38
38
|
program
|
|
39
39
|
.name('haystack')
|
|
40
40
|
.description('Haystack CLI — automated PR review, triage, and merge queue')
|
|
41
|
-
.version('0.
|
|
41
|
+
.version('0.13.0');
|
|
42
42
|
program
|
|
43
43
|
.command('init')
|
|
44
44
|
.description('Create .haystack.json configuration')
|
|
@@ -97,6 +97,8 @@ program
|
|
|
97
97
|
.option('--auto-fix', 'Alpha auto-fix for straightforward mechanical issues (discouraged)')
|
|
98
98
|
.option('--auto-merge', 'Apply auto-merge label (default: from .haystack.json, --no-auto-merge to disable)')
|
|
99
99
|
.option('--no-wait', 'Skip waiting for analysis results')
|
|
100
|
+
.option('--max-turns <n>', 'Max agentic turns per triage checker (overrides .haystack.json triage.maxTurns)', (v) => parseInt(v, 10))
|
|
101
|
+
.option('--triage-timeout <seconds>', 'Wall-clock timeout per triage checker in seconds (overrides .haystack.json triage.timeoutMs)', (v) => parseInt(v, 10))
|
|
100
102
|
.addHelpText('after', `
|
|
101
103
|
This command is designed for AI coding agents to submit PRs.
|
|
102
104
|
|
|
@@ -116,6 +118,22 @@ Pre-PR Triage:
|
|
|
116
118
|
Use --auto-fix only when explicitly opting into the alpha fixer for
|
|
117
119
|
straightforward mechanical issues.
|
|
118
120
|
|
|
121
|
+
Triage budgets:
|
|
122
|
+
• --max-turns <n> Raise/lower the per-checker tool-use turn cap.
|
|
123
|
+
Defaults: code-review 8, rules-validator 10,
|
|
124
|
+
intent-drift 10. Applies the same N to all three.
|
|
125
|
+
• --triage-timeout <sec> Raise/lower the per-checker wall-clock timeout
|
|
126
|
+
(default: 180s).
|
|
127
|
+
|
|
128
|
+
Persist these in .haystack.json to apply per project:
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
"triage": {
|
|
132
|
+
"maxTurns": { "code-review": 12 },
|
|
133
|
+
"timeoutMs": 240000
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
119
137
|
Review Routing:
|
|
120
138
|
If repo auto-merge is enabled in .haystack.json, plain \`haystack submit\`
|
|
121
139
|
enters the auto-merge queue after submission. Otherwise it still runs the
|
|
@@ -146,6 +164,8 @@ Examples:
|
|
|
146
164
|
haystack submit --draft # Create as draft PR
|
|
147
165
|
haystack submit --review # ⚠ Blocks auto-merge, needs human approval
|
|
148
166
|
haystack submit --review octocat # ⚠ Blocks auto-merge, requests review from octocat
|
|
167
|
+
haystack submit --max-turns 12 # Raise triage turn cap to 12 per checker
|
|
168
|
+
haystack submit --triage-timeout 300 # Raise triage wall-clock to 5 minutes
|
|
149
169
|
`)
|
|
150
170
|
.action(async (options) => {
|
|
151
171
|
// Resolve --auto-merge default from .haystack.json when not explicitly set
|
package/dist/triage/prompts.d.ts
CHANGED
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
* Build the code review prompt.
|
|
12
12
|
* Always runs — looks for objective bugs in the diff.
|
|
13
13
|
*/
|
|
14
|
-
export declare function buildCodeReviewPrompt(baseBranch: string, outputPath: string, precomputedDiff?: string | null): string;
|
|
14
|
+
export declare function buildCodeReviewPrompt(baseBranch: string, outputPath: string, maxTurns: number, timeoutMs: number, precomputedDiff?: string | null): string;
|
|
15
15
|
/**
|
|
16
16
|
* Build the rules validator prompt.
|
|
17
17
|
* Runs if .haystack/pr-rules.yml exists OR agent instruction files are found.
|
|
18
18
|
*
|
|
19
19
|
* @returns The prompt string, or null if no rules content or agent policies provided.
|
|
20
20
|
*/
|
|
21
|
-
export declare function buildRulesValidatorPrompt(baseBranch: string, rulesYaml: string, outputPath: string, agentPolicies?: {
|
|
21
|
+
export declare function buildRulesValidatorPrompt(baseBranch: string, rulesYaml: string, outputPath: string, maxTurns: number, timeoutMs: number, agentPolicies?: {
|
|
22
22
|
filename: string;
|
|
23
23
|
content: string;
|
|
24
24
|
}[], precomputedDiff?: string | null): string | null;
|
|
@@ -28,4 +28,4 @@ export declare function buildRulesValidatorPrompt(baseBranch: string, rulesYaml:
|
|
|
28
28
|
*
|
|
29
29
|
* @returns The prompt string, or null if no trace files provided.
|
|
30
30
|
*/
|
|
31
|
-
export declare function buildIntentDriftPrompt(baseBranch: string, traceFiles: string[], outputPath: string, precomputedDiff?: string | null): string | null;
|
|
31
|
+
export declare function buildIntentDriftPrompt(baseBranch: string, traceFiles: string[], outputPath: string, maxTurns: number, timeoutMs: number, precomputedDiff?: string | null): string | null;
|
package/dist/triage/prompts.js
CHANGED
|
@@ -55,11 +55,29 @@ const INTENT_DRIFT_SCHEMA = `{
|
|
|
55
55
|
// ============================================================================
|
|
56
56
|
// Prompt builders
|
|
57
57
|
// ============================================================================
|
|
58
|
+
/**
|
|
59
|
+
* Build the time-budget preamble shared by every checker prompt. Agents don't
|
|
60
|
+
* otherwise know about the externally-enforced turn cap and wall-clock
|
|
61
|
+
* deadline, so they'd happily burn budget on deep dives and get SIGTERM'd.
|
|
62
|
+
* Telling them up front makes them triage instead of exhaustively explore.
|
|
63
|
+
*/
|
|
64
|
+
function buildTimeBudgetHeader(maxTurns, timeoutMs) {
|
|
65
|
+
const minutes = Math.round(timeoutMs / 60_000);
|
|
66
|
+
return `## Time budget (hard limits)
|
|
67
|
+
|
|
68
|
+
- You have **${maxTurns} tool-use turns max** and **~${minutes} minutes wall-clock** before this process is killed.
|
|
69
|
+
- Be decisive. Skip speculative exploration. If a file looks incidental, don't open it.
|
|
70
|
+
- Prefer the provided diff over running fresh searches unless the diff alone is ambiguous.
|
|
71
|
+
- Report only findings you can confirm quickly. A timeout produces ZERO findings, so ship a partial high-confidence result rather than chase a perfect one that never lands.
|
|
72
|
+
- Write the output JSON file EARLY (even if partial) and update it if you find more. Never exit without writing.
|
|
73
|
+
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
58
76
|
/**
|
|
59
77
|
* Build the code review prompt.
|
|
60
78
|
* Always runs — looks for objective bugs in the diff.
|
|
61
79
|
*/
|
|
62
|
-
export function buildCodeReviewPrompt(baseBranch, outputPath, precomputedDiff) {
|
|
80
|
+
export function buildCodeReviewPrompt(baseBranch, outputPath, maxTurns, timeoutMs, precomputedDiff) {
|
|
63
81
|
const diffSection = precomputedDiff
|
|
64
82
|
? `## Diff (precomputed)
|
|
65
83
|
|
|
@@ -79,7 +97,7 @@ ${precomputedDiff}
|
|
|
79
97
|
3. Identify only REAL BUGS — things that will definitely crash, produce wrong results, or corrupt data.`;
|
|
80
98
|
return `You are a pre-PR code reviewer. Your job is to find OBJECTIVE BUGS in the code changes that will cause incorrect runtime behavior.
|
|
81
99
|
|
|
82
|
-
${diffSection}
|
|
100
|
+
${buildTimeBudgetHeader(maxTurns, timeoutMs)}${diffSection}
|
|
83
101
|
|
|
84
102
|
## What to flag
|
|
85
103
|
|
|
@@ -122,7 +140,7 @@ Be extremely conservative. False positives waste the developer's time. Only flag
|
|
|
122
140
|
*
|
|
123
141
|
* @returns The prompt string, or null if no rules content or agent policies provided.
|
|
124
142
|
*/
|
|
125
|
-
export function buildRulesValidatorPrompt(baseBranch, rulesYaml, outputPath, agentPolicies, precomputedDiff) {
|
|
143
|
+
export function buildRulesValidatorPrompt(baseBranch, rulesYaml, outputPath, maxTurns, timeoutMs, agentPolicies, precomputedDiff) {
|
|
126
144
|
const hasRules = rulesYaml.trim().length > 0;
|
|
127
145
|
const hasPolicies = agentPolicies && agentPolicies.length > 0;
|
|
128
146
|
if (!hasRules && !hasPolicies)
|
|
@@ -196,7 +214,7 @@ ${precomputedDiff}
|
|
|
196
214
|
3. If you need more context, read just the relevant section of the file — do NOT read entire large files.`;
|
|
197
215
|
return `You are a PR rules validator. Your job is to check the code changes against project rules and policies, then flag violations.
|
|
198
216
|
|
|
199
|
-
${rulesSection}${policiesSection}${diffInstructions}
|
|
217
|
+
${buildTimeBudgetHeader(maxTurns, timeoutMs)}${rulesSection}${policiesSection}${diffInstructions}
|
|
200
218
|
|
|
201
219
|
## Important
|
|
202
220
|
|
|
@@ -223,13 +241,13 @@ ${RULES_VALIDATOR_SCHEMA}
|
|
|
223
241
|
*
|
|
224
242
|
* @returns The prompt string, or null if no trace files provided.
|
|
225
243
|
*/
|
|
226
|
-
export function buildIntentDriftPrompt(baseBranch, traceFiles, outputPath, precomputedDiff) {
|
|
244
|
+
export function buildIntentDriftPrompt(baseBranch, traceFiles, outputPath, maxTurns, timeoutMs, precomputedDiff) {
|
|
227
245
|
if (traceFiles.length === 0)
|
|
228
246
|
return null;
|
|
229
247
|
const traceFileList = traceFiles.map(f => `- \`${f}\``).join('\n');
|
|
230
248
|
return `You are an intent drift detector. Your job is to check whether an AI coding agent faithfully implemented what the user asked for.
|
|
231
249
|
|
|
232
|
-
## Context
|
|
250
|
+
${buildTimeBudgetHeader(maxTurns, timeoutMs)}## Context
|
|
233
251
|
|
|
234
252
|
This PR was created by an AI coding agent. The agent's session transcripts (traces) are stored locally. You will compare what the user asked the agent to do against what was actually implemented in the diff.
|
|
235
253
|
|
package/dist/triage/runner.d.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* parallel sub-processes for each checker, and collects structured JSON results.
|
|
6
6
|
*/
|
|
7
7
|
import type { AgenticCLI, TriageResult } from './types.js';
|
|
8
|
+
export interface RunTriageOptions {
|
|
9
|
+
/** Per-checker max-turn overrides (CLI flag > .haystack.json > defaults). */
|
|
10
|
+
maxTurns?: Partial<Record<string, number>>;
|
|
11
|
+
/** Per-checker wall-clock timeout override, in ms. */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
8
14
|
/**
|
|
9
15
|
* Detect which agentic CLI is installed.
|
|
10
16
|
* Returns the first one found, or null if none available.
|
|
@@ -18,4 +24,4 @@ export declare function detectAgenticCLI(): AgenticCLI | null;
|
|
|
18
24
|
* @returns Aggregated triage results
|
|
19
25
|
* @throws If no agentic CLI is detected
|
|
20
26
|
*/
|
|
21
|
-
export declare function runTriage(gitRoot: string, baseBranch: string): Promise<TriageResult>;
|
|
27
|
+
export declare function runTriage(gitRoot: string, baseBranch: string, options?: RunTriageOptions): Promise<TriageResult>;
|
package/dist/triage/runner.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 ${
|
|
138
|
-
},
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
325
|
-
|
|
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
|
}
|
package/dist/triage/types.d.ts
CHANGED
|
@@ -39,8 +39,10 @@ export type CheckerResult = CodeReviewResult | RulesValidatorResult | IntentDrif
|
|
|
39
39
|
export interface TriageResult {
|
|
40
40
|
passed: boolean;
|
|
41
41
|
results: CheckerResult[];
|
|
42
|
-
/**
|
|
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
|
});
|