@haystackeditor/cli 0.15.31 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +141 -61
  2. package/dist/assets/hooks/scripts/commit-msg.sh +3 -0
  3. package/dist/assets/hooks/scripts/post-commit.sh +3 -0
  4. package/dist/assets/hooks/scripts/pre-commit.sh +11 -6
  5. package/dist/assets/hooks/scripts/pre-push.sh +3 -0
  6. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
  7. package/dist/commands/case-batch-contract.js +694 -0
  8. package/dist/commands/case-batch.js +1011 -0
  9. package/dist/commands/cloud-verifier-identity-census.js +5 -2
  10. package/dist/commands/combination-search-hook.js +139 -0
  11. package/dist/commands/dismiss.js +2 -1
  12. package/dist/commands/hooks.js +66 -7
  13. package/dist/commands/install-session-hooks.js +131 -44
  14. package/dist/commands/policy.js +32 -45
  15. package/dist/commands/precompute-delivery.js +6 -1
  16. package/dist/commands/scaffold-provisional-universe.js +8 -10
  17. package/dist/commands/setup.js +32 -10
  18. package/dist/commands/submit.js +39 -71
  19. package/dist/commands/telemetry.js +17 -2
  20. package/dist/commands/triage.js +2 -1
  21. package/dist/commands/verify-explore.js +1 -0
  22. package/dist/commands/verify-hosted-mcp.js +3 -12
  23. package/dist/commands/verify-hosted-reproducibility.js +49 -547
  24. package/dist/commands/verify-hosted.js +66 -168
  25. package/dist/commands/verify-precompute.js +64 -11
  26. package/dist/commands/verify.js +51 -139
  27. package/dist/index.js +290 -366
  28. package/dist/lazy.js +8 -0
  29. package/dist/schema.js +2 -1
  30. package/dist/tools/detect.js +3 -24
  31. package/dist/triage/astra.js +199 -0
  32. package/dist/triage/prompts.js +145 -179
  33. package/dist/triage/runner.js +104 -300
  34. package/dist/triage/types.js +2 -2
  35. package/dist/types.js +2 -6
  36. package/dist/utils/auth.js +14 -2
  37. package/dist/utils/git.js +60 -29
  38. package/dist/utils/github-api.js +14 -1
  39. package/dist/utils/haystack-api.js +38 -7
  40. package/dist/utils/hooks.js +43 -6
  41. package/dist/utils/prompter.js +25 -10
  42. package/dist/utils/safe-write.js +31 -0
  43. package/dist/utils/secret-paths.js +115 -0
  44. package/dist/utils/secrets.js +0 -1
  45. package/dist/utils/telemetry.js +34 -14
  46. package/dist/utils/update-check.js +151 -0
  47. package/package.json +19 -14
  48. package/schemas/case-batch.v1.json +235 -0
  49. package/schemas/cloud-verifier.v1.json +55 -100
  50. package/schemas/submit.v1.json +4 -2
  51. package/dist/commands/ask.d.ts +0 -14
  52. package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
  53. package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
  54. package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
  55. package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
  56. package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
  57. package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
  58. package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
  59. package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
  60. package/dist/commands/cloud-verifier-universe.d.ts +0 -31
  61. package/dist/commands/config.d.ts +0 -46
  62. package/dist/commands/design-verify.d.ts +0 -33
  63. package/dist/commands/dismiss.d.ts +0 -29
  64. package/dist/commands/hooks.d.ts +0 -13
  65. package/dist/commands/inbox.d.ts +0 -65
  66. package/dist/commands/init.d.ts +0 -10
  67. package/dist/commands/install-session-hooks.d.ts +0 -17
  68. package/dist/commands/login.d.ts +0 -8
  69. package/dist/commands/mcp.d.ts +0 -1
  70. package/dist/commands/policy.d.ts +0 -31
  71. package/dist/commands/pr-status.d.ts +0 -144
  72. package/dist/commands/pr.d.ts +0 -40
  73. package/dist/commands/precompute-delivery-contract.d.ts +0 -68
  74. package/dist/commands/precompute-delivery.d.ts +0 -20
  75. package/dist/commands/prepare-universe-review.d.ts +0 -115
  76. package/dist/commands/production-source-deny-policy.d.ts +0 -15
  77. package/dist/commands/request-review.d.ts +0 -26
  78. package/dist/commands/review.d.ts +0 -25
  79. package/dist/commands/rules.d.ts +0 -4
  80. package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
  81. package/dist/commands/schema-cmd.d.ts +0 -2
  82. package/dist/commands/setup.d.ts +0 -28
  83. package/dist/commands/skills.d.ts +0 -8
  84. package/dist/commands/status.d.ts +0 -4
  85. package/dist/commands/submit.d.ts +0 -30
  86. package/dist/commands/system-map.d.ts +0 -42
  87. package/dist/commands/telemetry.d.ts +0 -53
  88. package/dist/commands/tokens.d.ts +0 -14
  89. package/dist/commands/triage.d.ts +0 -35
  90. package/dist/commands/verify-core.d.ts +0 -449
  91. package/dist/commands/verify-core.js +0 -789
  92. package/dist/commands/verify-explore.d.ts +0 -14
  93. package/dist/commands/verify-history.d.ts +0 -14
  94. package/dist/commands/verify-hosted-mcp.d.ts +0 -12
  95. package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
  96. package/dist/commands/verify-hosted.d.ts +0 -92
  97. package/dist/commands/verify-mcp.d.ts +0 -8
  98. package/dist/commands/verify-mcp.js +0 -517
  99. package/dist/commands/verify-ops.d.ts +0 -158
  100. package/dist/commands/verify-ops.js +0 -1148
  101. package/dist/commands/verify-precompute.d.ts +0 -30
  102. package/dist/commands/verify-reproducibility.d.ts +0 -85
  103. package/dist/commands/verify-reproducibility.js +0 -494
  104. package/dist/commands/verify-reseal.d.ts +0 -9
  105. package/dist/commands/verify-reseal.js +0 -148
  106. package/dist/commands/verify-sandboxes.d.ts +0 -95
  107. package/dist/commands/verify-sandboxes.js +0 -352
  108. package/dist/commands/verify.d.ts +0 -28
  109. package/dist/commands/webhooks.d.ts +0 -30
  110. package/dist/index.d.ts +0 -22
  111. package/dist/schema.d.ts +0 -28
  112. package/dist/states.d.ts +0 -29
  113. package/dist/tools/detect.d.ts +0 -50
  114. package/dist/triage/prompts.d.ts +0 -24
  115. package/dist/triage/runner.d.ts +0 -34
  116. package/dist/triage/types.d.ts +0 -42
  117. package/dist/types/verify-history.d.ts +0 -38
  118. package/dist/types.d.ts +0 -1684
  119. package/dist/utils/action-output.d.ts +0 -24
  120. package/dist/utils/analysis-api.d.ts +0 -187
  121. package/dist/utils/auth.d.ts +0 -79
  122. package/dist/utils/config.d.ts +0 -24
  123. package/dist/utils/design-verifier-api.d.ts +0 -208
  124. package/dist/utils/design-verifier-history.d.ts +0 -46
  125. package/dist/utils/design-verifier-result.d.ts +0 -74
  126. package/dist/utils/detect.d.ts +0 -43
  127. package/dist/utils/git.d.ts +0 -135
  128. package/dist/utils/github-api.d.ts +0 -104
  129. package/dist/utils/haystack-api.d.ts +0 -37
  130. package/dist/utils/hooks.d.ts +0 -12
  131. package/dist/utils/pending-state.d.ts +0 -40
  132. package/dist/utils/pr-ref.d.ts +0 -27
  133. package/dist/utils/prompter.d.ts +0 -85
  134. package/dist/utils/secrets.d.ts +0 -47
  135. package/dist/utils/telemetry.d.ts +0 -19
  136. /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
@@ -1,31 +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 the highest reasoning effort (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 { execSync, spawn } from 'child_process';
8
- import { existsSync, readFileSync, mkdirSync, rmSync } from 'fs';
9
+ import { execFileSync } from 'child_process';
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
- /** 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
  /**
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.
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 DEFAULT_MAX_TURNS = {
26
- 'code-review': 8,
27
- 'rules-validator': 10,
28
- };
27
+ const DEFAULT_STALL_MS = 300_000;
29
28
  // ============================================================================
30
29
  // Agent policy file discovery
31
30
  // ============================================================================
@@ -54,218 +53,53 @@ function discoverAgentPolicyFiles(gitRoot) {
54
53
  }
55
54
  return results;
56
55
  }
57
- // ============================================================================
58
- // CLI detection
59
- // ============================================================================
60
- /**
61
- * Detect which agentic CLI is installed.
62
- * Returns the first one found, or null if none available.
63
- */
64
- export function detectAgenticCLI() {
65
- const isWindows = process.platform === 'win32';
66
- const inCodexSession = Boolean(process.env.CODEX_SHELL || process.env.CODEX_THREAD_ID || process.env.CODEX_CI);
67
- const inClaudeSession = Boolean(process.env.CLAUDE_CODE || process.env.CLAUDECODE);
68
- const clis = inCodexSession
69
- ? ['codex', 'claude', 'gemini']
70
- : inClaudeSession
71
- ? ['claude', 'codex', 'gemini']
72
- : ['claude', 'codex', 'gemini'];
73
- for (const name of clis) {
74
- try {
75
- execSync(isWindows ? `where ${name}` : `which ${name}`, { stdio: 'ignore' });
76
- return name;
77
- }
78
- catch {
79
- // Not installed
80
- }
81
- }
82
- return null;
83
- }
84
- /**
85
- * Build the CLI command + args for the detected agentic tool.
86
- */
87
- export function buildCommand(cli, prompt, maxTurns) {
88
- switch (cli) {
89
- case 'claude':
90
- return {
91
- command: 'claude',
92
- args: [
93
- '-p', prompt,
94
- '--dangerously-skip-permissions',
95
- '--max-turns', String(maxTurns),
96
- '--allowedTools', 'Bash', 'Read', 'Glob', 'Grep', 'Write',
97
- ],
98
- };
99
- case 'codex':
100
- return {
101
- command: 'codex',
102
- args: [
103
- '--sandbox', 'workspace-write',
104
- '--ask-for-approval', 'never',
105
- 'exec',
106
- '--ephemeral',
107
- prompt,
108
- ],
109
- };
110
- case 'gemini':
111
- return {
112
- command: 'gemini',
113
- args: ['-p', prompt, '-y'],
114
- };
115
- }
116
- }
56
+ const CHECKER_LABEL = {
57
+ 'code-review': 'code review',
58
+ 'rules-validator': 'rules validator',
59
+ };
117
60
  /**
118
- * Spawn a sub-agent process and wait for it to complete.
119
- * 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.
120
65
  */
121
- function spawnChecker(cli, config, cwd, timeoutMs) {
122
- return new Promise((resolve) => {
123
- const { command, args } = buildCommand(cli, config.prompt, config.maxTurns);
124
- let stdout = '';
125
- let stderr = '';
126
- const proc = spawn(command, args, {
127
- cwd,
128
- stdio: ['ignore', 'pipe', 'pipe'],
129
- env: {
130
- ...process.env,
131
- CLAUDECODE: undefined,
132
- },
133
- });
134
- proc.stdout?.on('data', (data) => {
135
- stdout += data.toString();
136
- });
137
- proc.stderr?.on('data', (data) => {
138
- stderr += data.toString();
139
- });
140
- // Timeout
141
- const timer = setTimeout(() => {
142
- proc.kill('SIGTERM');
143
- stderr += `\nProcess killed after ${Math.round(timeoutMs / 1000)}s timeout`;
144
- }, timeoutMs);
145
- proc.on('close', (code) => {
146
- clearTimeout(timer);
147
- resolve({ exitCode: code ?? 1, output: `${stdout}\n${stderr}`.trim() });
148
- });
149
- proc.on('error', (err) => {
150
- clearTimeout(timer);
151
- resolve({ exitCode: 1, output: err.message });
152
- });
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;
153
82
  });
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
- }
179
- function summarizeCheckerFailureOutput(output) {
180
- const lines = output
181
- .split('\n')
182
- .map(line => line.trim())
183
- .filter(Boolean);
184
- if (lines.length === 0)
185
- return null;
186
- const priorityPatterns = [
187
- /not logged in/i,
188
- /stream disconnected|error sending request|failed to refresh available models/i,
189
- /operation not permitted|readonly database|permission denied/i,
190
- /timeout|timed out/i,
191
- /unauthorized|forbidden/i,
192
- /^error:/i,
193
- /failed/i,
194
- ];
195
- let summary = lines[lines.length - 1];
196
- for (const pattern of priorityPatterns) {
197
- const match = lines.find(line => pattern.test(line));
198
- if (match) {
199
- summary = match;
200
- break;
201
- }
202
- }
203
- return summary.length > 220
204
- ? `${summary.slice(0, 217)}...`
205
- : summary;
206
- }
207
- // ============================================================================
208
- // Result reading
209
- // ============================================================================
210
- /**
211
- * Read and validate a checker result JSON file.
212
- */
213
- function readCheckerResult(outputPath, checkerName) {
214
- if (!existsSync(outputPath)) {
215
- return null;
216
- }
217
- try {
218
- const content = readFileSync(outputPath, 'utf-8');
219
- const result = JSON.parse(content);
220
- // Basic validation
221
- if (!result.checker || !Array.isArray(result.issues) || typeof result.passed !== 'boolean') {
222
- return null;
223
- }
224
- // Ensure issues have required fields
225
- result.issues = result.issues.filter((issue) => issue.file && issue.message && issue.severity);
226
- // Recalculate passed based on issues
227
- result.passed = !result.issues.some((i) => i.severity === 'error');
228
- return result;
229
- }
230
- catch {
231
- return null;
232
- }
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 };
233
88
  }
234
89
  // ============================================================================
235
90
  // Main runner
236
91
  // ============================================================================
237
92
  /**
238
- * Run pre-PR triage checks using parallel sub-agent processes.
93
+ * Run pre-PR triage checks in parallel.
239
94
  *
240
95
  * @param gitRoot - Absolute path to the git repository root
241
96
  * @param baseBranch - The base branch to diff against (e.g., "main")
242
97
  * @returns Aggregated triage results
243
- * @throws If no agentic CLI is detected
244
98
  */
245
99
  export async function runTriage(gitRoot, baseBranch, options = {}) {
246
100
  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;
257
- // Detect CLI
258
- const cli = detectAgenticCLI();
259
- if (!cli) {
260
- throw new Error('No agentic CLI found. Install Claude Code (claude), Codex CLI (codex), or Gemini CLI (gemini).');
261
- }
262
- // Fast-fail in Codex app shells where outbound network is disabled.
263
- // Spawning nested `codex exec` will fail after retries; skip triage immediately instead.
264
- const codexNetworkDisabled = process.env.CODEX_SANDBOX_NETWORK_DISABLED === '1';
265
- if (cli === 'codex' && codexNetworkDisabled) {
266
- 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.');
267
- }
268
- 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})`));
269
103
  // Prepare output directory
270
104
  const triageDir = join(gitRoot, TRIAGE_DIR);
271
105
  if (existsSync(triageDir)) {
@@ -274,123 +108,93 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
274
108
  mkdirSync(triageDir, { recursive: true });
275
109
  // Resolve the ref to diff against once. Prefers origin/<base> over the bare
276
110
  // local <base> ref (which is often stale and balloons the diff with already-
277
- // merged code). Every git read below — the precomputed diff, the agent diff
278
- // commands in the prompts use this single resolved ref so they all see the
279
- // same fork point.
111
+ // merged code).
280
112
  const diffBaseRef = resolveDiffBaseRef(baseBranch);
281
113
  if (diffBaseRef !== baseBranch) {
282
114
  console.log(chalk.dim(` Diff base: ${diffBaseRef}`));
283
115
  }
284
- // Pre-compute the diff once so sub-agents don't waste turns running git diff.
285
- // Includes 10 lines of context around each hunk for reviewability.
286
- let precomputedDiff = null;
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;
287
119
  try {
288
- const diffOutput = execSync(`git diff ${diffBaseRef}...HEAD -U10`, { cwd: gitRoot, encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 }).trim();
289
- // Only inline if the diff is under 100K chars — beyond that, let the agent
290
- // run git diff itself (it can paginate or review file-by-file).
291
- if (diffOutput.length > 0 && diffOutput.length <= 100_000) {
292
- precomputedDiff = diffOutput;
293
- }
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();
294
121
  }
295
- catch {
296
- // git diff failed — agents will run it themselves
122
+ catch (err) {
123
+ const stderr = err?.stderr?.toString().trim();
124
+ const cause = stderr || (err instanceof Error ? err.message : String(err));
125
+ throw new Error(`Could not compute the triage diff against ${diffBaseRef}: ${cause}`);
297
126
  }
127
+ writeFileSync(join(triageDir, 'diff.patch'), diff);
298
128
  // Build checker configs
299
129
  const checkers = [];
300
130
  // 1. Code review (always runs)
301
- const codeReviewOutput = join(triageDir, 'code-review.json');
302
131
  checkers.push({
303
132
  name: 'code-review',
304
- prompt: buildCodeReviewPrompt(diffBaseRef, codeReviewOutput, maxTurns['code-review'], timeoutMs, precomputedDiff),
305
- outputFile: codeReviewOutput,
306
- maxTurns: maxTurns['code-review'],
133
+ prompt: buildCodeReviewPrompt(diffBaseRef, diff),
134
+ schema: CODE_REVIEW_SCHEMA,
135
+ outputFile: join(triageDir, 'code-review.json'),
307
136
  });
308
137
  // 2. Rules validator (if pr-rules.yml or agent instruction files exist)
309
138
  const rulesPath = join(gitRoot, '.haystack', 'pr-rules.yml');
310
- const hasRulesYaml = existsSync(rulesPath);
311
- const rulesYaml = hasRulesYaml ? readFileSync(rulesPath, 'utf-8') : '';
312
- const agentPolicyFiles = discoverAgentPolicyFiles(gitRoot);
313
- if (hasRulesYaml || agentPolicyFiles.length > 0) {
314
- const rulesValidatorOutput = join(triageDir, 'rules-validator.json');
315
- const rulesPrompt = buildRulesValidatorPrompt(diffBaseRef, rulesYaml, rulesValidatorOutput, maxTurns['rules-validator'], timeoutMs, agentPolicyFiles, precomputedDiff);
316
- if (rulesPrompt) {
317
- checkers.push({
318
- name: 'rules-validator',
319
- prompt: rulesPrompt,
320
- outputFile: rulesValidatorOutput,
321
- maxTurns: maxTurns['rules-validator'],
322
- });
323
- }
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
+ });
324
148
  }
325
- // Log what we're running
326
- const checkerNames = checkers.map(c => c.name);
327
- console.log(chalk.dim(` Checkers: ${checkerNames.join(', ')}`));
149
+ console.log(chalk.dim(` Checkers: ${checkers.map(c => c.name).join(', ')}`));
328
150
  console.log(chalk.dim(` Running ${checkers.length} checker(s) in parallel...\n`));
329
- // Spawn all checkers in parallel
330
- const spawnResults = await Promise.allSettled(checkers.map(async (checker) => {
331
- const startLabel = chalk.dim(` [${checker.name}]`);
332
- console.log(`${startLabel} Starting...`);
333
- const result = await spawnChecker(cli, checker, gitRoot, timeoutMs);
334
- if (result.exitCode === 0) {
335
- 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;
336
166
  }
337
- else {
338
- console.log(`${startLabel} ${chalk.yellow(`Exited with code ${result.exitCode}`)}`);
167
+ catch (err) {
168
+ console.log(`${label} ${chalk.yellow('Failed')}`);
169
+ throw err;
339
170
  }
340
- return { checker, result };
341
171
  }));
342
- // Collect results
343
172
  const results = [];
344
173
  const errors = [];
345
- const warnings = [];
346
- for (const spawnResult of spawnResults) {
347
- if (spawnResult.status === 'rejected') {
348
- errors.push(`Checker failed: ${spawnResult.reason}`);
349
- trackError('triage_checker_failed', {
350
- checker: 'unknown',
351
- kind: 'spawn_rejected',
352
- cli,
353
- reason: String(spawnResult.reason),
354
- });
355
- continue;
356
- }
357
- const { checker, result } = spawnResult.value;
358
- const checkerResult = readCheckerResult(checker.outputFile, checker.name);
359
- if (checkerResult) {
360
- results.push(checkerResult);
361
- continue;
362
- }
363
- // Sub-agent didn't produce valid output — classify as warning vs hard error.
364
- const classification = classifyCheckerFailure(result.output);
365
- if (classification.kind === 'max_turns') {
366
- 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`);
367
- }
368
- else if (classification.kind === 'timeout') {
369
- warnings.push(`${checker.name}: wall-clock timeout after ${Math.round(timeoutMs / 1000)}s — consider splitting the PR`);
370
- }
371
- else {
372
- const exitPart = result.exitCode !== 0 ? ` (exit ${result.exitCode})` : '';
373
- 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;
374
182
  }
375
- // 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}`);
376
185
  trackError('triage_checker_failed', {
377
186
  checker: checker.name,
378
- kind: classification.kind,
379
- cli,
380
- max_turns: checker.maxTurns,
381
- timeout_ms: timeoutMs,
382
- exit_code: result.exitCode,
383
- summary: classification.summary,
187
+ model: TRIAGE_MODEL,
188
+ stall_ms: stallMs,
189
+ summary: message.slice(0, 220),
384
190
  });
385
- }
386
- // Warnings are non-blocking by design — they're expected outcomes of the
387
- // turn/time caps, not signs of real problems.
388
- 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.
389
194
  return {
390
- passed,
195
+ passed: results.every(r => r.passed),
391
196
  results,
392
197
  errors,
393
- warnings,
394
198
  duration: Date.now() - startTime,
395
199
  };
396
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
@@ -117,7 +117,6 @@ export const AI_REVIEWER_BOT_USERNAMES = {
117
117
  codereviewer: 'codereviewbot[bot]',
118
118
  codium: 'codiumai[bot]',
119
119
  };
120
- const AIReviewerSourceSchema = z.enum(AI_REVIEWER_SOURCES);
121
120
  const MergeQueueConfigSchema = z.object({
122
121
  /** Enable the Haystack auto-merge queue */
123
122
  merge_queue: z.boolean().optional(),
@@ -272,11 +271,8 @@ const SecretDeclarationSchema = z.object({
272
271
  // =============================================================================
273
272
  // TRIAGE CONFIGURATION
274
273
  // =============================================================================
275
- const TriageCheckerNameSchema = z.enum(['code-review', 'rules-validator']);
276
274
  const TriageConfigSchema = z.object({
277
- /** Per-checker max agentic turns. Unspecified checkers use built-in defaults. */
278
- maxTurns: z.record(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
  });
@@ -18,6 +18,15 @@ const TOKEN_FILE_DISPLAY = '~/.haystack/credentials.json';
18
18
  */
19
19
  export const HEADLESS_TOKEN_FILE = path.join(CONFIG_DIR, 'cli-token');
20
20
  const HEADLESS_TOKEN_FILE_DISPLAY = '~/.haystack/cli-token';
21
+ const HEADLESS_TOKEN_PREFIX = 'hsk_live_';
22
+ /**
23
+ * True for a Haystack headless token (`hsk_live_*`). These authenticate to
24
+ * Haystack only: they must never be sent to api.github.com or used as a git
25
+ * push password, where they would leak to GitHub and fail anyway.
26
+ */
27
+ export function isHeadlessToken(token) {
28
+ return token.startsWith(HEADLESS_TOKEN_PREFIX);
29
+ }
21
30
  const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}$/;
22
31
  function emptyStore() {
23
32
  return {
@@ -362,13 +371,13 @@ export class NotLoggedInError extends Error {
362
371
  * on-disk file so CI can override without touching the home directory. */
363
372
  export async function readHeadlessToken() {
364
373
  const envToken = process.env.HAYSTACK_CLI_TOKEN;
365
- if (envToken && envToken.startsWith('hsk_live_')) {
374
+ if (envToken && isHeadlessToken(envToken)) {
366
375
  return { token: envToken, label: 'env:HAYSTACK_CLI_TOKEN' };
367
376
  }
368
377
  try {
369
378
  const raw = await fs.readFile(HEADLESS_TOKEN_FILE, 'utf8');
370
379
  const parsed = JSON.parse(raw);
371
- if (typeof parsed.token === 'string' && parsed.token.startsWith('hsk_live_')) {
380
+ if (typeof parsed.token === 'string' && isHeadlessToken(parsed.token)) {
372
381
  return parsed;
373
382
  }
374
383
  return null;
@@ -385,6 +394,9 @@ export async function writeHeadlessToken(record) {
385
394
  await fs.writeFile(HEADLESS_TOKEN_FILE, JSON.stringify(record, null, 2) + '\n', {
386
395
  mode: 0o600,
387
396
  });
397
+ // writeFile's mode only applies when it creates the file; tighten an
398
+ // existing file too, as saveStore does.
399
+ await fs.chmod(HEADLESS_TOKEN_FILE, 0o600);
388
400
  return HEADLESS_TOKEN_FILE_DISPLAY;
389
401
  }
390
402
  export async function clearHeadlessToken() {