@haystackeditor/cli 0.15.11 → 0.15.13

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.
@@ -433,7 +433,16 @@ export async function submitCommand(options) {
433
433
  try {
434
434
  await ensureHaystackLabels(remoteInfo.owner, remoteInfo.repo, ['haystack:auto-merge'], githubToken);
435
435
  await addLabelsToIssue(remoteInfo.owner, remoteInfo.repo, prNumber, ['haystack:auto-merge'], githubToken);
436
- console.log(chalk.green('✓ Auto-merge enabled for this PR'));
436
+ // With --review, the PR still enters the auto-merge queue but the
437
+ // merge queue's needs-review gate holds it until a human approves
438
+ // (removes haystack:needs-review or marks it reviewed). Don't print a
439
+ // bare "Auto-merge enabled" that reads as if it'll merge unreviewed.
440
+ if (options.review) {
441
+ console.log(chalk.green('✓ Auto-merge enabled (held until a human approves this PR)'));
442
+ }
443
+ else {
444
+ console.log(chalk.green('✓ Auto-merge enabled for this PR'));
445
+ }
437
446
  }
438
447
  catch (err) {
439
448
  console.log(chalk.yellow(`⚠ Could not set auto-merge label: ${err.message}`));
@@ -482,7 +491,15 @@ export async function submitCommand(options) {
482
491
  console.log(` ${chalk.dim('Title:')} ${options.title || getLastCommitMessage()}`);
483
492
  console.log(` ${chalk.dim('Branch:')} ${currentBranch} → ${baseBranch}`);
484
493
  if (options.autoMerge) {
485
- console.log(` ${chalk.dim('Merge:')} ${chalk.green('Auto-merge if safe')}`);
494
+ // --review and auto-merge are not contradictory: the PR is queued but the
495
+ // merge queue's needs-review gate blocks the merge until a human approves.
496
+ // Report it that way instead of a flat "Auto-merge if safe".
497
+ if (options.review) {
498
+ console.log(` ${chalk.dim('Merge:')} ${chalk.yellow('Held for human review, then auto-merge')}`);
499
+ }
500
+ else {
501
+ console.log(` ${chalk.dim('Merge:')} ${chalk.green('Auto-merge if safe')}`);
502
+ }
486
503
  }
487
504
  if (options.autoFix) {
488
505
  console.log(` ${chalk.dim('Fix:')} ${chalk.cyan('Auto-fix alpha enabled')}`);
package/dist/index.js CHANGED
@@ -42,7 +42,12 @@ import { schemaCommand, listSchemas } from './commands/schema-cmd.js';
42
42
  import { registerWebhook, listWebhooks, rotateWebhookSecret, setWebhookEnabled, listDeliveries, replayDelivery, } from './commands/webhooks.js';
43
43
  import { editRulesCommand, validateRulesCommand } from './commands/rules.js';
44
44
  import { headlessLoginCommand, headlessLogoutCommand, listTokensCommand, revokeTokenCommand, } from './commands/tokens.js';
45
- import { runMcpServer } from './commands/mcp.js';
45
+ // `mcp` is imported lazily inside its command action (below), NOT at the top level.
46
+ // It is the only module that pulls in `@modelcontextprotocol/sdk`; keeping it out of
47
+ // the startup import graph means a missing/broken SDK can never crash core commands
48
+ // like `triage`/`submit`. (0.15.12 shipped `mcp.js` but published its package.json
49
+ // WITHOUT `@modelcontextprotocol/sdk`, so the eager import here hard-crashed every
50
+ // command at startup with ERR_MODULE_NOT_FOUND.)
46
51
  // pr-state read helpers (parsePrRef, prReadCommand) are imported by the MCP
47
52
  // server directly from ./commands/pr.js; no top-level CLI command is wired
48
53
  // here. The previous `pr <ref> [show|merge|snooze|dismiss]` and `fix <ref>`
@@ -818,10 +823,19 @@ program
818
823
  .description('Run a stdio MCP server exposing Haystack tools to Claude Code, Cursor, etc.')
819
824
  .action(async () => {
820
825
  try {
826
+ // Lazy import: only this command needs @modelcontextprotocol/sdk, so we keep it
827
+ // out of the startup graph (see the note where the eager import used to be).
828
+ const { runMcpServer } = await import('./commands/mcp.js');
821
829
  await runMcpServer();
822
830
  }
823
831
  catch (err) {
824
- console.error(chalk.red('mcp server failed:'), err instanceof Error ? err.message : err);
832
+ const code = err?.code;
833
+ if (code === 'ERR_MODULE_NOT_FOUND') {
834
+ console.error(chalk.red('mcp server failed:'), 'the @modelcontextprotocol/sdk package is missing. Reinstall the CLI to pull it in (e.g. `npm i -g @haystackeditor/cli@latest`).');
835
+ }
836
+ else {
837
+ console.error(chalk.red('mcp server failed:'), err instanceof Error ? err.message : err);
838
+ }
825
839
  process.exit(1);
826
840
  }
827
841
  });
@@ -10,6 +10,7 @@ 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 { resolveDiffBaseRef } from '../utils/git.js';
13
14
  import { trackError } from '../utils/telemetry.js';
14
15
  // ============================================================================
15
16
  // Constants
@@ -272,11 +273,20 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
272
273
  rmSync(triageDir, { recursive: true });
273
274
  }
274
275
  mkdirSync(triageDir, { recursive: true });
276
+ // Resolve the ref to diff against once. Prefers origin/<base> over the bare
277
+ // local <base> ref (which is often stale and balloons the diff with already-
278
+ // merged code). Every git read below — the precomputed diff, the agent diff
279
+ // commands in the prompts, and the changed-file scan in findRelevantTraces —
280
+ // uses this single resolved ref so they all see the same fork point.
281
+ const diffBaseRef = resolveDiffBaseRef(baseBranch);
282
+ if (diffBaseRef !== baseBranch) {
283
+ console.log(chalk.dim(` Diff base: ${diffBaseRef}`));
284
+ }
275
285
  // Pre-compute the diff once so sub-agents don't waste turns running git diff.
276
286
  // Includes 10 lines of context around each hunk for reviewability.
277
287
  let precomputedDiff = null;
278
288
  try {
279
- const diffOutput = execSync(`git diff ${baseBranch}...HEAD -U10`, { cwd: gitRoot, encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 }).trim();
289
+ const diffOutput = execSync(`git diff ${diffBaseRef}...HEAD -U10`, { cwd: gitRoot, encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 }).trim();
280
290
  // Only inline if the diff is under 100K chars — beyond that, let the agent
281
291
  // run git diff itself (it can paginate or review file-by-file).
282
292
  if (diffOutput.length > 0 && diffOutput.length <= 100_000) {
@@ -292,7 +302,7 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
292
302
  const codeReviewOutput = join(triageDir, 'code-review.json');
293
303
  checkers.push({
294
304
  name: 'code-review',
295
- prompt: buildCodeReviewPrompt(baseBranch, codeReviewOutput, maxTurns['code-review'], timeoutMs, precomputedDiff),
305
+ prompt: buildCodeReviewPrompt(diffBaseRef, codeReviewOutput, maxTurns['code-review'], timeoutMs, precomputedDiff),
296
306
  outputFile: codeReviewOutput,
297
307
  maxTurns: maxTurns['code-review'],
298
308
  });
@@ -303,7 +313,7 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
303
313
  const agentPolicyFiles = discoverAgentPolicyFiles(gitRoot);
304
314
  if (hasRulesYaml || agentPolicyFiles.length > 0) {
305
315
  const rulesValidatorOutput = join(triageDir, 'rules-validator.json');
306
- const rulesPrompt = buildRulesValidatorPrompt(baseBranch, rulesYaml, rulesValidatorOutput, maxTurns['rules-validator'], timeoutMs, agentPolicyFiles, precomputedDiff);
316
+ const rulesPrompt = buildRulesValidatorPrompt(diffBaseRef, rulesYaml, rulesValidatorOutput, maxTurns['rules-validator'], timeoutMs, agentPolicyFiles, precomputedDiff);
307
317
  if (rulesPrompt) {
308
318
  checkers.push({
309
319
  name: 'rules-validator',
@@ -314,10 +324,10 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
314
324
  }
315
325
  }
316
326
  // 3. Intent drift (only if relevant trace files exist)
317
- const traceFiles = findRelevantTraces(gitRoot, baseBranch);
327
+ const traceFiles = findRelevantTraces(gitRoot, diffBaseRef);
318
328
  if (traceFiles.length > 0) {
319
329
  const intentDriftOutput = join(triageDir, 'intent-drift.json');
320
- const driftPrompt = buildIntentDriftPrompt(baseBranch, traceFiles, intentDriftOutput, maxTurns['intent-drift'], timeoutMs, precomputedDiff);
330
+ const driftPrompt = buildIntentDriftPrompt(diffBaseRef, traceFiles, intentDriftOutput, maxTurns['intent-drift'], timeoutMs, precomputedDiff);
321
331
  if (driftPrompt) {
322
332
  checkers.push({
323
333
  name: 'intent-drift',
@@ -90,6 +90,38 @@ export declare function getLastCommitSha(): string;
90
90
  * Returns the combined log, or empty string if no commits or on error.
91
91
  */
92
92
  export declare function getCommitMessagesSinceBase(baseBranch: string): string;
93
+ /**
94
+ * Resolve the git ref to diff a PR branch against for review.
95
+ *
96
+ * A bare local `<base>` ref is frequently stale (not checked out or pulled in a
97
+ * while). When it sits behind HEAD's true fork point, the three-dot merge-base
98
+ * jumps far back in history and inflates the review diff to include code from
99
+ * already-merged PRs — reviewers then mis-attribute pre-existing code to this
100
+ * PR. So we prefer the freshly-fetched remote base. This mirrors
101
+ * getCommitMessagesSinceBase, which already compares against the remote base.
102
+ *
103
+ * Returns a fully-qualified ref (`refs/remotes/origin/<base>` or
104
+ * `refs/heads/<base>`) so a tag that happens to share the name can never shadow
105
+ * the ref we mean — `git rev-parse <name>` prefers refs/tags/* over both
106
+ * refs/heads/* and refs/remotes/*. The bare `<base>` name is only returned as a
107
+ * last resort when nothing qualified resolves (the eventual `git diff` then
108
+ * errors and the caller falls back to running it itself).
109
+ *
110
+ * Resolution:
111
+ * 1. Best-effort fetch the remote base (`haystack submit` is already online —
112
+ * it pushes and opens a PR — so one ref fetch is cheap).
113
+ * 2. If the fetch succeeded, the remote-tracking ref is the authoritative
114
+ * remote tip — use it (even over a local <base> with unpushed commits; the
115
+ * PR will merge into the remote base, not the local one).
116
+ * 3. If the fetch failed (offline, private origin without ambient git creds,
117
+ * base not on remote), don't blindly trust a possibly-stale *cached*
118
+ * remote-tracking ref — pick whichever of it and local <base> is more
119
+ * advanced (the one that is not an ancestor of the other), since a base
120
+ * behind HEAD's fork point is exactly what balloons the diff.
121
+ * 4. Fall back to whatever single qualified ref resolves, then the bare
122
+ * <base> name (preserves prior behavior rather than breaking triage).
123
+ */
124
+ export declare function resolveDiffBaseRef(baseBranch: string): string;
93
125
  /**
94
126
  * Get the number of commits ahead of base branch
95
127
  */
package/dist/utils/git.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Provides functions for git operations: branch management, push, remote parsing.
5
5
  */
6
- import { execSync } from 'child_process';
6
+ import { execFileSync, execSync } from 'child_process';
7
7
  import * as fs from 'fs';
8
8
  import * as os from 'os';
9
9
  import * as path from 'path';
@@ -366,6 +366,107 @@ export function getCommitMessagesSinceBase(baseBranch) {
366
366
  }
367
367
  }
368
368
  }
369
+ /** True if `ref` resolves to a commit in the current repo. */
370
+ function gitRefResolves(ref) {
371
+ try {
372
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', '--', `${ref}^{commit}`], {
373
+ stdio: ['pipe', 'pipe', 'pipe'],
374
+ });
375
+ return true;
376
+ }
377
+ catch {
378
+ return false;
379
+ }
380
+ }
381
+ /** True if `ancestor` is an ancestor of (or equal to) `descendant`. */
382
+ function gitIsAncestor(ancestor, descendant) {
383
+ try {
384
+ // exit 0 => ancestor; exit 1 => not; both non-throwing for us via stdio pipe
385
+ execFileSync('git', ['merge-base', '--is-ancestor', '--', ancestor, descendant], {
386
+ stdio: ['pipe', 'pipe', 'pipe'],
387
+ });
388
+ return true;
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ }
394
+ /**
395
+ * Resolve the git ref to diff a PR branch against for review.
396
+ *
397
+ * A bare local `<base>` ref is frequently stale (not checked out or pulled in a
398
+ * while). When it sits behind HEAD's true fork point, the three-dot merge-base
399
+ * jumps far back in history and inflates the review diff to include code from
400
+ * already-merged PRs — reviewers then mis-attribute pre-existing code to this
401
+ * PR. So we prefer the freshly-fetched remote base. This mirrors
402
+ * getCommitMessagesSinceBase, which already compares against the remote base.
403
+ *
404
+ * Returns a fully-qualified ref (`refs/remotes/origin/<base>` or
405
+ * `refs/heads/<base>`) so a tag that happens to share the name can never shadow
406
+ * the ref we mean — `git rev-parse <name>` prefers refs/tags/* over both
407
+ * refs/heads/* and refs/remotes/*. The bare `<base>` name is only returned as a
408
+ * last resort when nothing qualified resolves (the eventual `git diff` then
409
+ * errors and the caller falls back to running it itself).
410
+ *
411
+ * Resolution:
412
+ * 1. Best-effort fetch the remote base (`haystack submit` is already online —
413
+ * it pushes and opens a PR — so one ref fetch is cheap).
414
+ * 2. If the fetch succeeded, the remote-tracking ref is the authoritative
415
+ * remote tip — use it (even over a local <base> with unpushed commits; the
416
+ * PR will merge into the remote base, not the local one).
417
+ * 3. If the fetch failed (offline, private origin without ambient git creds,
418
+ * base not on remote), don't blindly trust a possibly-stale *cached*
419
+ * remote-tracking ref — pick whichever of it and local <base> is more
420
+ * advanced (the one that is not an ancestor of the other), since a base
421
+ * behind HEAD's fork point is exactly what balloons the diff.
422
+ * 4. Fall back to whatever single qualified ref resolves, then the bare
423
+ * <base> name (preserves prior behavior rather than breaking triage).
424
+ */
425
+ export function resolveDiffBaseRef(baseBranch) {
426
+ const remoteRef = `refs/remotes/origin/${baseBranch}`;
427
+ const localRef = `refs/heads/${baseBranch}`;
428
+ // Freshen the remote base so the merge-base reflects the true fork point.
429
+ // Fetch with a fully-qualified refspec rather than a bare
430
+ // `git fetch origin <base>`:
431
+ // - Explicit destination: in clones whose configured fetch refspec does
432
+ // not cover <base> — a `--single-branch` clone, or `haystack submit
433
+ // --base release/x` — a bare fetch updates only FETCH_HEAD and leaves
434
+ // refs/remotes/origin/<base> stale or absent.
435
+ // - Qualified source (`refs/heads/<base>`): if the remote has both a
436
+ // branch and a tag named <base> (e.g. a `release` / `v1.0` base), an
437
+ // unqualified source could resolve to the tag and force the
438
+ // remote-tracking ref to it, making triage diff against the tag.
439
+ // `+` force-updates the remote-tracking ref to the remote tip (correct even
440
+ // if <base> was rewound on the remote).
441
+ let fetched = false;
442
+ try {
443
+ execFileSync('git', ['fetch', 'origin', `+refs/heads/${baseBranch}:${remoteRef}`], {
444
+ stdio: ['pipe', 'pipe', 'pipe'],
445
+ });
446
+ fetched = true;
447
+ }
448
+ catch {
449
+ // Offline / no creds / base not on remote — fall through to degraded
450
+ // selection below using whatever refs already exist locally.
451
+ }
452
+ const remoteResolves = gitRefResolves(remoteRef);
453
+ // Happy path: we just refreshed the remote base, so it's authoritative.
454
+ if (fetched && remoteResolves)
455
+ return remoteRef;
456
+ // Degraded path: fetch failed (or didn't produce the ref). Prefer the more
457
+ // up-to-date of the cached remote ref and the local branch; only a base that
458
+ // lags HEAD's fork point balloons the diff, so pick the one that's ahead.
459
+ const localResolves = gitRefResolves(localRef);
460
+ if (remoteResolves && localResolves) {
461
+ // If the remote ref is an ancestor of the local ref, local is ahead → use it.
462
+ return gitIsAncestor(remoteRef, localRef) ? localRef : remoteRef;
463
+ }
464
+ if (remoteResolves)
465
+ return remoteRef;
466
+ if (localResolves)
467
+ return localRef;
468
+ return baseBranch;
469
+ }
369
470
  /**
370
471
  * Get the number of commits ahead of base branch
371
472
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.11",
3
+ "version": "0.15.13",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "dev": "tsc --watch",
12
12
  "start": "node dist/index.js",
13
13
  "check:schemas": "node scripts/check-schemas.mjs",
14
- "prepublishOnly": "npm run build"
14
+ "release": "node scripts/release.mjs",
15
+ "prepublishOnly": "node scripts/no-direct-publish.mjs"
15
16
  },
16
17
  "keywords": [
17
18
  "haystack",
@@ -33,6 +34,7 @@
33
34
  "url": "https://github.com/haystackeditor/haystack-review/issues"
34
35
  },
35
36
  "dependencies": {
37
+ "@modelcontextprotocol/sdk": "1.26.0",
36
38
  "chalk": "5.6.2",
37
39
  "commander": "12.1.0",
38
40
  "fast-glob": "3.3.3",