@haystackeditor/cli 0.15.10 → 0.15.12

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,17 +10,54 @@
10
10
  // ============================================================================
11
11
  // Constants
12
12
  // ============================================================================
13
- const PROD_API_BASE = 'https://8z9ssbamdj.execute-api.us-west-2.amazonaws.com/prod';
13
+ /**
14
+ * Analysis API base.
15
+ *
16
+ * IMPORTANT: this used to point directly at AWS API Gateway. That worked
17
+ * when the CLI carried a GitHub token (X-GitHub-Token header to the
18
+ * Lambda), but BROKE for `haystack login --headless` users — an
19
+ * hsk_live_* token would land on AWS as if it were a GH token, the
20
+ * Lambda would 401, and triage / pr / chat / MCP would silently return
21
+ * nothing. The right move is to route reads through the auth-worker,
22
+ * which:
23
+ *
24
+ * 1. Knows how to introspect hsk_live tokens (and GH tokens, agent JWTs,
25
+ * session cookies) — single normalized auth path.
26
+ * 2. Applies the hsk_live scope gate (denyForCliScope) before forwarding
27
+ * to AWS, so a repo-scoped CI token can't read other repos' synthesis.
28
+ * 3. Adds the right backend token (install token for org-installed reads,
29
+ * fallback otherwise) — so private-repo reads work without the CLI
30
+ * ever holding a GH token.
31
+ *
32
+ * Override with HAYSTACK_API_BASE for local dev (e.g., http://localhost:8788
33
+ * pointing at a local auth-worker).
34
+ */
35
+ const ANALYSIS_API_BASE = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
36
+ /** Build the auth-worker URL for a v3 result file (rating_synthesis.json, etc). */
37
+ function v3ResultUrl(owner, repo, prNumber, file) {
38
+ // The auth-worker exposes `/api/analysis/{prIdentifier}/{file}` (see
39
+ // V3_API_PATTERNS in infra/auth-worker/index.js). prIdentifier format:
40
+ // owner/repo#prnum, URL-encoded as %2F + %23.
41
+ const prId = encodeURIComponent(`${owner}/${repo}#${prNumber}`);
42
+ return `${ANALYSIS_API_BASE}/api/analysis/${prId}${file ? `/${file}` : ''}`;
43
+ }
14
44
  // ============================================================================
15
45
  // API helpers
16
46
  // ============================================================================
47
+ /**
48
+ * Build headers for an auth-worker-proxied analysis request.
49
+ *
50
+ * Auth-worker accepts hsk_live_*, GH tokens, agent JWTs, and session
51
+ * cookies on `Authorization: Bearer`. The legacy `X-GitHub-Token` header
52
+ * targeted AWS directly and is no longer used.
53
+ */
17
54
  function apiHeaders(token) {
18
55
  const headers = {
19
56
  'Cache-Control': 'no-cache',
20
57
  'User-Agent': 'Haystack-CLI',
21
58
  };
22
59
  if (token) {
23
- headers['X-GitHub-Token'] = token;
60
+ headers['Authorization'] = `Bearer ${token}`;
24
61
  }
25
62
  return headers;
26
63
  }
@@ -32,7 +69,7 @@ function apiHeaders(token) {
32
69
  * Returns 'ready' if complete, 'pending' if not yet available, or 'error' for server failures.
33
70
  */
34
71
  export async function checkAnalysisReady(owner, repo, prNumber, token) {
35
- const url = `${PROD_API_BASE}/v3/result/${owner}/${repo}/${prNumber}`;
72
+ const url = v3ResultUrl(owner, repo, prNumber);
36
73
  let response;
37
74
  try {
38
75
  response = await fetch(url, {
@@ -57,97 +94,130 @@ export async function checkAnalysisReady(owner, repo, prNumber, token) {
57
94
  return data.status === 'COMPLETED' ? { status: 'ready' } : { status: 'pending' };
58
95
  }
59
96
  /**
60
- * Fetch analysis results once complete. Combines bug detection and rule violations.
97
+ * Fetch analysis results once complete. Used by `haystack submit`'s
98
+ * wait-for-triage loop to render a verdict block.
99
+ *
100
+ * Verdict semantics — three distinct signals, never conflated:
101
+ *
102
+ * state = is the analysis itself clean?
103
+ * → 'good-to-merge' iff analysisVerdict === 'clean'
104
+ * AND !needsHumanReview. canAutoMerge is NOT
105
+ * the source of truth (it only means "analysis
106
+ * would let this through" — review policy,
107
+ * grace period, or a missing auto-merge label
108
+ * can still block).
109
+ * Falls back to haystackRating ≥ 5 only when
110
+ * analysisVerdict is absent (legacy payloads).
111
+ * needsHumanReview = preserved on the return shape so callers can
112
+ * distinguish "clean + needs human" from clean.
113
+ * autoMerged = was the PR ACTUALLY merged? Read from the
114
+ * GitHub REST endpoint, not from anything in
115
+ * the synthesis. canAutoMerge is eligibility,
116
+ * not outcome — never claim "auto-merged" from
117
+ * the analysis side.
118
+ *
119
+ * Sources from the v3 rating-synthesis path (auth-worker-proxied — so
120
+ * hsk_live tokens, GH tokens, agent JWTs, and cookies all work, and the
121
+ * scope gate runs). The legacy v2 bug-detection / rule-violation /
122
+ * merge-decision JSONs are no longer queried (no auth-worker proxy).
61
123
  */
62
- export async function fetchAnalysisResults(owner, repo, prNumber, token) {
63
- const prIdentifier = `${owner}/${repo}#${prNumber}`;
64
- const encodedId = encodeURIComponent(prIdentifier);
65
- const reviewUrl = `https://haystackeditor.com/review/${owner}/${repo}/${prNumber}`;
66
- const issues = [];
67
- // Fetch bug detection
68
- try {
69
- const bugUrl = `${PROD_API_BASE}/v2/analysis/${encodedId}/bug_detection.json`;
70
- const bugResp = await fetch(bugUrl, {
71
- headers: apiHeaders(token),
72
- });
73
- if (bugResp.ok) {
74
- const bugData = await bugResp.json();
75
- if (bugData.bugs) {
76
- for (const bug of bugData.bugs) {
77
- issues.push({
78
- file: bug.file || 'unknown',
79
- line: bug.line,
80
- message: bug.description || 'Bug detected',
81
- severity: bug.severity === 'critical' || bug.severity === 'high' ? 'error' : 'warning',
82
- source: 'bug-detection',
83
- });
84
- }
85
- }
86
- }
87
- }
88
- catch {
89
- // Bug detection not available — not fatal
90
- }
91
- // Fetch rule violations
124
+ async function isPrMerged(owner, repo, prNumber, token) {
125
+ // Authoritative merge check. We hit the same auth-worker proxy as the
126
+ // rest of the CLI so hsk_live / OAuth / install tokens all work, AND
127
+ // scope enforcement happens here too (a token not scoped to this repo
128
+ // would 403; we treat that as "unknown → not merged" rather than
129
+ // crashing the submit flow).
130
+ if (!token)
131
+ return false;
92
132
  try {
93
- const rulesUrl = `${PROD_API_BASE}/v2/analysis/${encodedId}/rule_violations.json`;
94
- const rulesResp = await fetch(rulesUrl, {
95
- headers: apiHeaders(token),
133
+ const base = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
134
+ const res = await fetch(`${base}/api/github/repos/${owner}/${repo}/pulls/${prNumber}`, {
135
+ headers: {
136
+ Accept: 'application/vnd.github+json',
137
+ Authorization: `Bearer ${token}`,
138
+ 'User-Agent': 'Haystack-CLI',
139
+ },
96
140
  });
97
- if (rulesResp.ok) {
98
- const rulesData = await rulesResp.json();
99
- if (rulesData.violations) {
100
- for (const v of rulesData.violations) {
101
- issues.push({
102
- file: v.file || 'unknown',
103
- line: v.line,
104
- message: v.message || `Rule violation: ${v.rule || 'unknown'}`,
105
- severity: v.severity === 'error' ? 'error' : 'warning',
106
- source: 'rule-violation',
107
- });
108
- }
109
- }
110
- }
141
+ if (!res.ok)
142
+ return false;
143
+ const data = (await res.json());
144
+ return data.merged === true;
111
145
  }
112
146
  catch {
113
- // Rule violations not available — not fatal
147
+ return false;
114
148
  }
115
- // Fetch merge decision (to check if auto-merged)
116
- let autoMerged = false;
117
- try {
118
- const mergeUrl = `${PROD_API_BASE}/v2/analysis/${encodedId}/merge_decision.json`;
119
- const mergeResp = await fetch(mergeUrl, {
120
- headers: apiHeaders(token),
121
- });
122
- if (mergeResp.ok) {
123
- const mergeData = await mergeResp.json();
124
- autoMerged = mergeData.autoMerged === true;
125
- }
149
+ }
150
+ export async function fetchAnalysisResults(owner, repo, prNumber, token) {
151
+ const reviewUrl = `https://haystackeditor.com/review/${owner}/${repo}/${prNumber}`;
152
+ const [synthesis, autoMerged] = await Promise.all([
153
+ fetchRatingSynthesis(owner, repo, prNumber, token),
154
+ isPrMerged(owner, repo, prNumber, token),
155
+ ]);
156
+ if (!synthesis) {
157
+ return {
158
+ state: 'needs-input',
159
+ bugCount: 0,
160
+ warningCount: 0,
161
+ ruleViolationCount: 0,
162
+ summary: 'Analysis is ready but synthesis could not be loaded.',
163
+ issues: [],
164
+ reviewUrl,
165
+ autoMerged,
166
+ };
126
167
  }
127
- catch {
128
- // Merge decision not available — not fatal
129
- }
130
- const bugCount = issues.filter(i => i.source === 'bug-detection').length;
131
- const ruleViolationCount = issues.filter(i => i.source === 'rule-violation').length;
132
- const warningCount = issues.filter(i => i.severity === 'warning').length;
133
- const errorCount = issues.filter(i => i.severity === 'error').length;
134
- const state = errorCount === 0 && warningCount === 0 ? 'good-to-merge' : 'needs-input';
135
- // Build summary
168
+ const display = synthesis.synthesisDisplay ?? [];
169
+ const issues = display.map((d) => ({
170
+ file: 'unknown', // synthesis findings don't always carry a path; the rich data lives on the feed
171
+ message: d.summary || d.detail || `Finding (${d.category})`,
172
+ severity: 'warning',
173
+ // Map the synthesis source onto the verdict's two-bucket model.
174
+ // Anything other than an explicit rule violation lands on bug-detection.
175
+ source: d.source === 'rule-violation' ? 'rule-violation' : 'bug-detection',
176
+ }));
177
+ const bugCount = issues.filter((i) => i.source === 'bug-detection').length;
178
+ const ruleViolationCount = issues.filter((i) => i.source === 'rule-violation').length;
179
+ const warningCount = issues.length;
180
+ // Three discrete signals, NEVER conflated:
181
+ // verdictKind : 'clean' | 'has-issues' | 'needs-review'
182
+ // (legacy payloads without analysisVerdict fall back
183
+ // to haystackRating ≥ 5 → 'clean')
184
+ // needsHumanReview: true if review policy demands a human, OR if the
185
+ // orchestrator escalated verdict to 'needs-review'
186
+ // (which carries the same semantic).
187
+ // analysisIsClean : true iff verdict is 'clean' OR 'needs-review'
188
+ // (both mean: no bugs/violations were found; the
189
+ // review-policy gate is the separate axis).
190
+ //
191
+ // good-to-merge = analysis clean AND no human required.
192
+ // 'has-issues' → state 'needs-input'.
193
+ // 'needs-review' → state 'needs-input' + needsHumanReview true.
194
+ // 'clean' + needsHumanReview → same as 'needs-review' (defensive — the
195
+ // orchestrator should already have escalated, but we handle the case
196
+ // where it didn't).
197
+ const verdictKind = synthesis.analysisVerdict
198
+ ?? (synthesis.haystackRating >= 5 ? 'clean' : 'has-issues');
199
+ const analysisIsClean = verdictKind === 'clean' || verdictKind === 'needs-review';
200
+ const needsHumanReview = synthesis.needsHumanReview === true || verdictKind === 'needs-review';
201
+ const isGoodToMerge = analysisIsClean && !needsHumanReview;
202
+ const state = isGoodToMerge ? 'good-to-merge' : 'needs-input';
136
203
  let summary;
137
204
  if (state === 'good-to-merge') {
138
- summary = autoMerged ? 'No issues found. PR was auto-merged.' : 'No issues found. Safe to merge.';
205
+ summary = autoMerged
206
+ ? 'No blocking issues. PR was merged.'
207
+ : 'No blocking issues. Safe to merge.';
208
+ }
209
+ else if (analysisIsClean && needsHumanReview) {
210
+ summary = 'Analysis is clean, but review policy requires a human reviewer.';
139
211
  }
140
212
  else {
141
213
  const parts = [];
142
- if (errorCount > 0)
143
- parts.push(`${errorCount} error(s)`);
144
- if (warningCount > 0)
145
- parts.push(`${warningCount} warning(s)`);
146
214
  if (bugCount > 0)
147
- parts.push(`${bugCount} bug(s)`);
215
+ parts.push(`${bugCount} finding(s)`);
148
216
  if (ruleViolationCount > 0)
149
217
  parts.push(`${ruleViolationCount} rule violation(s)`);
150
- summary = parts.join(', ') + ' found.';
218
+ summary = parts.length > 0
219
+ ? parts.join(', ') + ' found.'
220
+ : `Rating ${synthesis.haystackRating}/5 — see the feed for details.`;
151
221
  }
152
222
  return {
153
223
  state,
@@ -158,10 +228,11 @@ export async function fetchAnalysisResults(owner, repo, prNumber, token) {
158
228
  issues,
159
229
  reviewUrl,
160
230
  autoMerged,
231
+ needsHumanReview,
161
232
  };
162
233
  }
163
234
  export async function fetchAutoFixManifest(owner, repo, prNumber, token) {
164
- const url = `${PROD_API_BASE}/v3/result/${owner}/${repo}/${prNumber}/auto-fix-manifest.json`;
235
+ const url = v3ResultUrl(owner, repo, prNumber, 'auto-fix-manifest.json');
165
236
  try {
166
237
  const response = await fetch(url, {
167
238
  headers: apiHeaders(token),
@@ -184,8 +255,10 @@ export async function fetchAutoFixManifest(owner, repo, prNumber, token) {
184
255
  }
185
256
  }
186
257
  export async function fetchRatingSynthesis(owner, repo, prNumber, token) {
187
- // Use v3/result endpoint (same as the Lambda serves generic files)
188
- const url = `${PROD_API_BASE}/v3/result/${owner}/${repo}/${prNumber}/rating_synthesis.json`;
258
+ // Route through the auth-worker (/api/analysis/{prId}/rating_synthesis.json)
259
+ // so hsk_live tokens are introspected and the scope gate applies — see
260
+ // ANALYSIS_API_BASE comment above.
261
+ const url = v3ResultUrl(owner, repo, prNumber, 'rating_synthesis.json');
189
262
  try {
190
263
  const response = await fetch(url, {
191
264
  headers: apiHeaders(token),
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Path to a long-lived `hsk_live_*` token created by `haystack login --headless`.
3
+ * If present (or HAYSTACK_CLI_TOKEN is set), it takes priority over the
4
+ * regular OAuth credentials. CI environments use this exclusively.
5
+ *
6
+ * Why a separate file (not just an entry in credentials.json):
7
+ * 1. Different lifecycle — the OAuth flow renews + refreshes; hsk_live
8
+ * tokens are mint-once-revoke-never until explicit revocation.
9
+ * 2. Easier to commit accidentally and easier to .gitignore (one file).
10
+ * 3. Different shape — hsk_live tokens carry server-side scope that the
11
+ * OAuth token doesn't, and we don't want to model both in one schema.
12
+ */
13
+ export declare const HEADLESS_TOKEN_FILE: string;
14
+ interface HeadlessTokenFile {
15
+ token: string;
16
+ id?: string;
17
+ label?: string;
18
+ created_at?: string;
19
+ }
1
20
  export interface StoredAccount {
2
21
  github_token: string;
3
22
  created_at: string;
@@ -46,5 +65,15 @@ export declare function removeAccount(login?: string): Promise<string | null>;
46
65
  export declare function getActiveLogin(): Promise<string | null>;
47
66
  export declare function getRepoDefaultAccount(owner: string, repo: string): Promise<string | null>;
48
67
  export declare function setRepoDefaultAccount(owner: string, repo: string, login: string): Promise<void>;
68
+ /** Thrown by {@link resolveAuthContext} when no usable credential is found. */
69
+ export declare class NotLoggedInError extends Error {
70
+ constructor(message?: string);
71
+ }
72
+ /** Read the headless CLI token if one is configured. Env var wins over the
73
+ * on-disk file so CI can override without touching the home directory. */
74
+ export declare function readHeadlessToken(): Promise<HeadlessTokenFile | null>;
75
+ export declare function writeHeadlessToken(record: HeadlessTokenFile): Promise<string>;
76
+ export declare function clearHeadlessToken(): Promise<void>;
49
77
  export declare function resolveAuthContext(options?: ResolveAuthOptions): Promise<ResolvedAuthContext>;
50
78
  export declare function loadToken(options?: ResolveAuthOptions): Promise<string | null>;
79
+ export {};
@@ -4,6 +4,20 @@ import * as path from 'path';
4
4
  const CONFIG_DIR = path.join(os.homedir(), '.haystack');
5
5
  const TOKEN_FILE = path.join(CONFIG_DIR, 'credentials.json');
6
6
  const TOKEN_FILE_DISPLAY = '~/.haystack/credentials.json';
7
+ /**
8
+ * Path to a long-lived `hsk_live_*` token created by `haystack login --headless`.
9
+ * If present (or HAYSTACK_CLI_TOKEN is set), it takes priority over the
10
+ * regular OAuth credentials. CI environments use this exclusively.
11
+ *
12
+ * Why a separate file (not just an entry in credentials.json):
13
+ * 1. Different lifecycle — the OAuth flow renews + refreshes; hsk_live
14
+ * tokens are mint-once-revoke-never until explicit revocation.
15
+ * 2. Easier to commit accidentally and easier to .gitignore (one file).
16
+ * 3. Different shape — hsk_live tokens carry server-side scope that the
17
+ * OAuth token doesn't, and we don't want to model both in one schema.
18
+ */
19
+ export const HEADLESS_TOKEN_FILE = path.join(CONFIG_DIR, 'cli-token');
20
+ const HEADLESS_TOKEN_FILE_DISPLAY = '~/.haystack/cli-token';
7
21
  const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}$/;
8
22
  function emptyStore() {
9
23
  return {
@@ -337,7 +351,60 @@ export async function setRepoDefaultAccount(owner, repo, login) {
337
351
  store.repoDefaults[key] = storedLogin;
338
352
  await saveStore(store);
339
353
  }
354
+ /** Thrown by {@link resolveAuthContext} when no usable credential is found. */
355
+ export class NotLoggedInError extends Error {
356
+ constructor(message = 'Not logged in. Run `haystack login` first.') {
357
+ super(message);
358
+ this.name = 'NotLoggedInError';
359
+ }
360
+ }
361
+ /** Read the headless CLI token if one is configured. Env var wins over the
362
+ * on-disk file so CI can override without touching the home directory. */
363
+ export async function readHeadlessToken() {
364
+ const envToken = process.env.HAYSTACK_CLI_TOKEN;
365
+ if (envToken && envToken.startsWith('hsk_live_')) {
366
+ return { token: envToken, label: 'env:HAYSTACK_CLI_TOKEN' };
367
+ }
368
+ try {
369
+ const raw = await fs.readFile(HEADLESS_TOKEN_FILE, 'utf8');
370
+ const parsed = JSON.parse(raw);
371
+ if (typeof parsed.token === 'string' && parsed.token.startsWith('hsk_live_')) {
372
+ return parsed;
373
+ }
374
+ return null;
375
+ }
376
+ catch {
377
+ return null;
378
+ }
379
+ }
380
+ export async function writeHeadlessToken(record) {
381
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
382
+ // 0600 so other users on a shared machine can't read the token. The file
383
+ // contains a long-lived credential — mode matters here in a way that doesn't
384
+ // for credentials.json, which can be re-minted via OAuth at any time.
385
+ await fs.writeFile(HEADLESS_TOKEN_FILE, JSON.stringify(record, null, 2) + '\n', {
386
+ mode: 0o600,
387
+ });
388
+ return HEADLESS_TOKEN_FILE_DISPLAY;
389
+ }
390
+ export async function clearHeadlessToken() {
391
+ try {
392
+ await fs.unlink(HEADLESS_TOKEN_FILE);
393
+ }
394
+ catch { /* not present is fine */ }
395
+ }
340
396
  export async function resolveAuthContext(options = {}) {
397
+ // hsk_live tokens win unconditionally. They were created explicitly by
398
+ // the operator with `haystack login --headless` (or set via env in CI)
399
+ // — that intent should not be overridden by a stale OAuth cookie.
400
+ const headless = await readHeadlessToken();
401
+ if (headless) {
402
+ return {
403
+ login: headless.label ?? 'headless',
404
+ token: headless.token,
405
+ source: 'active',
406
+ };
407
+ }
341
408
  const store = await loadCredentialsStore();
342
409
  const { preferredLogin, owner, repo } = options;
343
410
  if (preferredLogin) {
@@ -370,7 +437,7 @@ export async function resolveAuthContext(options = {}) {
370
437
  source: 'active',
371
438
  };
372
439
  }
373
- throw new Error('Not logged in. Run `haystack login` first.');
440
+ throw new NotLoggedInError();
374
441
  }
375
442
  export async function loadToken(options = {}) {
376
443
  try {
@@ -378,7 +445,8 @@ export async function loadToken(options = {}) {
378
445
  return context.token;
379
446
  }
380
447
  catch (error) {
381
- if (error instanceof Error && error.message.startsWith('Not logged in.')) {
448
+ // Classify by type, not message text (see pr-rules.yml PR007).
449
+ if (error instanceof NotLoggedInError) {
382
450
  return null;
383
451
  }
384
452
  throw error;
@@ -68,6 +68,10 @@ export interface PushAuth {
68
68
  * Without `auth`, falls back to `git push -u <remote>` using ambient creds.
69
69
  */
70
70
  export declare function pushBranch(remoteName: string, branchName: string, auth?: PushAuth): void;
71
+ /** Thrown when a git remote URL is not a recognized GitHub SSH/HTTPS URL. */
72
+ export declare class UnsupportedRemoteUrlError extends Error {
73
+ constructor(url: string);
74
+ }
71
75
  /**
72
76
  * Parse remote URL to extract owner and repo
73
77
  * Supports both SSH and HTTPS URLs
@@ -86,6 +90,38 @@ export declare function getLastCommitSha(): string;
86
90
  * Returns the combined log, or empty string if no commits or on error.
87
91
  */
88
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;
89
125
  /**
90
126
  * Get the number of commits ahead of base branch
91
127
  */
package/dist/utils/git.js CHANGED
@@ -257,6 +257,13 @@ function translatePushError(err, repoSlug) {
257
257
  // ============================================================================
258
258
  // Remote operations
259
259
  // ============================================================================
260
+ /** Thrown when a git remote URL is not a recognized GitHub SSH/HTTPS URL. */
261
+ export class UnsupportedRemoteUrlError extends Error {
262
+ constructor(url) {
263
+ super(`Unsupported remote URL format: ${url}`);
264
+ this.name = 'UnsupportedRemoteUrlError';
265
+ }
266
+ }
260
267
  /**
261
268
  * Parse remote URL to extract owner and repo
262
269
  * Supports both SSH and HTTPS URLs
@@ -285,10 +292,13 @@ export function parseRemoteUrl(remoteName = 'origin') {
285
292
  remoteName,
286
293
  };
287
294
  }
288
- throw new Error(`Unsupported remote URL format: ${url}`);
295
+ throw new UnsupportedRemoteUrlError(url);
289
296
  }
290
297
  catch (err) {
291
- if (err instanceof Error && err.message.includes('Unsupported')) {
298
+ // Propagate the "unrecognized URL" case as-is; wrap everything else (the
299
+ // `git remote get-url` command itself failing) in a friendlier message.
300
+ // Classify by type, not message text (see pr-rules.yml PR007).
301
+ if (err instanceof UnsupportedRemoteUrlError) {
292
302
  throw err;
293
303
  }
294
304
  throw new Error(`Failed to get remote URL for '${remoteName}'`);
@@ -356,6 +366,107 @@ export function getCommitMessagesSinceBase(baseBranch) {
356
366
  }
357
367
  }
358
368
  }
369
+ /** True if `ref` resolves to a commit in the current repo. */
370
+ function gitRefResolves(ref) {
371
+ try {
372
+ execSync(`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
+ execSync(`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
+ execSync(`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
+ }
359
470
  /**
360
471
  * Get the number of commits ahead of base branch
361
472
  */
@@ -139,7 +139,10 @@ class JsonPrompter {
139
139
  return id in this.answers;
140
140
  }
141
141
  emit(event) {
142
- process.stdout.write(JSON.stringify(event) + '\n');
142
+ // Stamp every NDJSON event with the setup schema version so agent
143
+ // consumers can pin a contract. See docs/SPEC-CLI-FIRST-CLASS.md.
144
+ const stamped = { schema_version: '1.0.0', ...event };
145
+ process.stdout.write(JSON.stringify(stamped) + '\n');
143
146
  }
144
147
  /** Lazily open stdin only when we actually need an answer the agent must send. */
145
148
  ensureStdin() {