@haystackeditor/cli 0.15.10 → 0.15.11

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/dist/index.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { readFileSync } from 'node:fs';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { dirname, join } from 'node:path';
25
+ import chalk from 'chalk';
25
26
  import { Command } from 'commander';
26
27
  import { statusCommand } from './commands/status.js';
27
28
  import { initCommand } from './commands/init.js';
@@ -37,6 +38,16 @@ import { dismissCommand, markReviewedCommand, undismissCommand } from './command
37
38
  import { requestReviewCommand } from './commands/request-review.js';
38
39
  import { prStatusCommand } from './commands/pr-status.js';
39
40
  import { setupCommand } from './commands/setup.js';
41
+ import { schemaCommand, listSchemas } from './commands/schema-cmd.js';
42
+ import { registerWebhook, listWebhooks, rotateWebhookSecret, setWebhookEnabled, listDeliveries, replayDelivery, } from './commands/webhooks.js';
43
+ import { editRulesCommand, validateRulesCommand } from './commands/rules.js';
44
+ import { headlessLoginCommand, headlessLogoutCommand, listTokensCommand, revokeTokenCommand, } from './commands/tokens.js';
45
+ import { runMcpServer } from './commands/mcp.js';
46
+ // pr-state read helpers (parsePrRef, prReadCommand) are imported by the MCP
47
+ // server directly from ./commands/pr.js; no top-level CLI command is wired
48
+ // here. The previous `pr <ref> [show|merge|snooze|dismiss]` and `fix <ref>`
49
+ // surfaces were scope creep on the CLI-as-platform spec; `haystack pr-status`
50
+ // already covered the read use case.
40
51
  /** Read the published version from package.json (dist/index.js → ../package.json)
41
52
  * so `haystack --version` can't drift from the package version. Falls back
42
53
  * gracefully so the flag never throws if the file can't be read. */
@@ -58,6 +69,16 @@ program
58
69
  .name('haystack')
59
70
  .description('Haystack CLI — automated PR review, triage, and merge queue')
60
71
  .version(getVersion());
72
+ program
73
+ .command('schema [name]')
74
+ .description('Print the JSON Schema for a command\'s --json output (triage, setup, inbox, pr, chat)')
75
+ .action((name) => {
76
+ if (!name) {
77
+ listSchemas();
78
+ return;
79
+ }
80
+ schemaCommand(name);
81
+ });
61
82
  program
62
83
  .command('init')
63
84
  .description('Create .haystack.json configuration')
@@ -122,12 +143,68 @@ program
122
143
  .action(statusCommand);
123
144
  program
124
145
  .command('login')
125
- .description('Add or refresh a saved GitHub account')
126
- .action(loginCommand);
146
+ .description('Add or refresh a saved GitHub account (use --headless for CI tokens)')
147
+ .option('--headless', 'Mint a long-lived hsk_live_* token instead of an interactive OAuth session')
148
+ .option('--label <name>', '(--headless) Label to display in the dashboard for this token')
149
+ .option('--scope <repos>', '(--headless) Comma-separated repos to scope the token to (or "all")', (val) => val.split(',').map((s) => s.trim()).filter(Boolean))
150
+ .option('--token <hsk_live_...>', '(--headless) Skip the browser handshake — paste a token minted in the dashboard')
151
+ .action(async (opts) => {
152
+ if (opts.headless) {
153
+ try {
154
+ await headlessLoginCommand({ label: opts.label, scope: opts.scope, token: opts.token });
155
+ }
156
+ catch (err) {
157
+ console.error(chalk.red('headless login failed:'), err instanceof Error ? err.message : err);
158
+ process.exit(1);
159
+ }
160
+ return;
161
+ }
162
+ return loginCommand();
163
+ });
127
164
  program
128
165
  .command('logout [login]')
129
- .description('Remove a saved GitHub account (defaults to the active account)')
130
- .action(logoutCommand);
166
+ .description('Remove a saved GitHub account (use --headless to remove the local CLI token)')
167
+ .option('--headless', 'Remove the local hsk_live_* token file (does not revoke server-side — use `haystack tokens revoke <id>`)')
168
+ .action(async (login, opts) => {
169
+ if (opts.headless) {
170
+ try {
171
+ await headlessLogoutCommand();
172
+ }
173
+ catch (err) {
174
+ console.error(chalk.red('headless logout failed:'), err instanceof Error ? err.message : err);
175
+ process.exit(1);
176
+ }
177
+ return;
178
+ }
179
+ return logoutCommand(login);
180
+ });
181
+ // ─── tokens (server-side hsk_live_*) ─────────────────────────────────────────
182
+ const tokens = program.command('tokens').description('Manage server-side hsk_live_* tokens');
183
+ tokens
184
+ .command('list')
185
+ .description('List your CLI tokens')
186
+ .option('--json', 'Machine-readable output')
187
+ .action(async (opts) => {
188
+ try {
189
+ await listTokensCommand(opts);
190
+ }
191
+ catch (err) {
192
+ console.error(chalk.red('list failed:'), err instanceof Error ? err.message : err);
193
+ process.exit(1);
194
+ }
195
+ });
196
+ tokens
197
+ .command('revoke <id>')
198
+ .description('Revoke a token by id (cannot be undone)')
199
+ .action(async (id) => {
200
+ try {
201
+ await revokeTokenCommand(id);
202
+ }
203
+ catch (err) {
204
+ console.error(chalk.red('revoke failed:'), err instanceof Error ? err.message : err);
205
+ process.exit(1);
206
+ }
207
+ });
131
208
  program
132
209
  .command('whoami')
133
210
  .description('Show the active Haystack GitHub account')
@@ -606,6 +683,148 @@ The generated policies appear in Haystack's "Human Review Needed"
606
683
  section when a PR touches matching files.
607
684
  `)
608
685
  .action(initPolicies);
686
+ // ─── webhooks ────────────────────────────────────────────────────────────────
687
+ //
688
+ // `haystack webhooks ...` — register outbound webhook subscriptions and inspect
689
+ // delivery status. Talks to the haystack-webhooks worker.
690
+ //
691
+ // See docs/SPEC-CLI-FIRST-CLASS.md §1 for the wire protocol (HMAC, retries,
692
+ // idempotency).
693
+ const webhooks = program
694
+ .command('webhooks')
695
+ .description('Manage outbound webhooks (events: triage.completed, fixer.completed, ...)');
696
+ webhooks
697
+ .command('register')
698
+ .description('Register a webhook URL for the current repo and mint a fresh HMAC secret')
699
+ .requiredOption('-u, --url <url>', 'Receiver URL (https only)')
700
+ .option('-e, --events <events>', 'Comma-separated event list', (val) => val.split(',').map((s) => s.trim()).filter(Boolean))
701
+ .option('--secret-env <name>', 'Environment-variable name where you\'ll stash the secret (recorded in .haystack.json)')
702
+ .option('--repo <owner/name>', 'Override the inferred repo')
703
+ .action(async (opts) => {
704
+ try {
705
+ await registerWebhook(opts);
706
+ }
707
+ catch (err) {
708
+ console.error(chalk.red('register failed:'), err instanceof Error ? err.message : err);
709
+ process.exit(1);
710
+ }
711
+ });
712
+ webhooks
713
+ .command('list')
714
+ .description('List active webhook registrations for the current repo')
715
+ .option('--repo <owner/name>', 'Override the inferred repo')
716
+ .option('--json', 'Machine-readable JSON output')
717
+ .action(async (opts) => {
718
+ try {
719
+ await listWebhooks(opts);
720
+ }
721
+ catch (err) {
722
+ console.error(chalk.red('list failed:'), err instanceof Error ? err.message : err);
723
+ process.exit(1);
724
+ }
725
+ });
726
+ webhooks
727
+ .command('rotate-secret <id>')
728
+ .description('Rotate the HMAC secret for a registration (prints the new secret once)')
729
+ .action(async (id) => {
730
+ try {
731
+ await rotateWebhookSecret(id);
732
+ }
733
+ catch (err) {
734
+ console.error(chalk.red('rotate-secret failed:'), err instanceof Error ? err.message : err);
735
+ process.exit(1);
736
+ }
737
+ });
738
+ webhooks
739
+ .command('disable <id>')
740
+ .description('Disable a registration (stops delivery; existing in-flight retries continue)')
741
+ .action(async (id) => {
742
+ try {
743
+ await setWebhookEnabled(id, false);
744
+ }
745
+ catch (err) {
746
+ console.error(chalk.red('disable failed:'), err instanceof Error ? err.message : err);
747
+ process.exit(1);
748
+ }
749
+ });
750
+ webhooks
751
+ .command('enable <id>')
752
+ .description('Re-enable a previously disabled registration')
753
+ .action(async (id) => {
754
+ try {
755
+ await setWebhookEnabled(id, true);
756
+ }
757
+ catch (err) {
758
+ console.error(chalk.red('enable failed:'), err instanceof Error ? err.message : err);
759
+ process.exit(1);
760
+ }
761
+ });
762
+ webhooks
763
+ .command('deliveries')
764
+ .description('Show recent webhook delivery attempts for the current repo')
765
+ .option('--repo <owner/name>', 'Override the inferred repo')
766
+ .option('--limit <n>', 'Number of deliveries (default 20)', (v) => parseInt(v, 10))
767
+ .option('--json', 'Machine-readable JSON output')
768
+ .action(async (opts) => {
769
+ try {
770
+ await listDeliveries(opts);
771
+ }
772
+ catch (err) {
773
+ console.error(chalk.red('deliveries failed:'), err instanceof Error ? err.message : err);
774
+ process.exit(1);
775
+ }
776
+ });
777
+ webhooks
778
+ .command('replay <id>')
779
+ .description('Replay a previous delivery (idempotent via X-Haystack-Delivery)')
780
+ .action(async (id) => {
781
+ try {
782
+ await replayDelivery(id);
783
+ }
784
+ catch (err) {
785
+ console.error(chalk.red('replay failed:'), err instanceof Error ? err.message : err);
786
+ process.exit(1);
787
+ }
788
+ });
789
+ // ─── rules ───────────────────────────────────────────────────────────────────
790
+ const rules = program.command('rules').description('Manage .haystack/pr-rules.yml');
791
+ rules
792
+ .command('edit')
793
+ .description('Open .haystack/pr-rules.yml in $EDITOR (creates a starter if missing)')
794
+ .action(async () => {
795
+ try {
796
+ await editRulesCommand();
797
+ }
798
+ catch (err) {
799
+ console.error(chalk.red('edit failed:'), err instanceof Error ? err.message : err);
800
+ process.exit(1);
801
+ }
802
+ });
803
+ rules
804
+ .command('validate')
805
+ .description('Parse pr-rules.yml with the same parser the server uses')
806
+ .action(async () => {
807
+ try {
808
+ await validateRulesCommand();
809
+ }
810
+ catch (err) {
811
+ console.error(chalk.red('validate failed:'), err instanceof Error ? err.message : err);
812
+ process.exit(1);
813
+ }
814
+ });
815
+ // ─── mcp ─────────────────────────────────────────────────────────────────────
816
+ program
817
+ .command('mcp')
818
+ .description('Run a stdio MCP server exposing Haystack tools to Claude Code, Cursor, etc.')
819
+ .action(async () => {
820
+ try {
821
+ await runMcpServer();
822
+ }
823
+ catch (err) {
824
+ console.error(chalk.red('mcp server failed:'), err instanceof Error ? err.message : err);
825
+ process.exit(1);
826
+ }
827
+ });
609
828
  // Show help if no command provided
610
829
  if (process.argv.length === 2) {
611
830
  program.help();
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Stable machine-readable schema versions for each `--json` output.
3
+ *
4
+ * Bump semantics (per docs/SPEC-CLI-FIRST-CLASS.md):
5
+ * patch — additive fields only
6
+ * minor — field deprecated (kept emitting for one minor with _deprecated: true)
7
+ * major — removal / rename
8
+ *
9
+ * One schema, two transports: every webhook payload for the corresponding noun
10
+ * uses the same envelope as the `--json` output, byte-for-byte.
11
+ */
12
+ export declare const SCHEMA_VERSIONS: {
13
+ readonly triage: "1.0.0";
14
+ readonly setup: "1.0.0";
15
+ readonly pr: "1.0.0";
16
+ };
17
+ export type SchemaName = keyof typeof SCHEMA_VERSIONS;
18
+ /** Wrap a payload with the `schema_version` envelope. */
19
+ export declare function withSchema<T extends Record<string, unknown>>(name: SchemaName, payload: T): T & {
20
+ schema_version: string;
21
+ };
package/dist/schema.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Stable machine-readable schema versions for each `--json` output.
3
+ *
4
+ * Bump semantics (per docs/SPEC-CLI-FIRST-CLASS.md):
5
+ * patch — additive fields only
6
+ * minor — field deprecated (kept emitting for one minor with _deprecated: true)
7
+ * major — removal / rename
8
+ *
9
+ * One schema, two transports: every webhook payload for the corresponding noun
10
+ * uses the same envelope as the `--json` output, byte-for-byte.
11
+ */
12
+ export const SCHEMA_VERSIONS = {
13
+ triage: '1.0.0',
14
+ setup: '1.0.0',
15
+ pr: '1.0.0',
16
+ };
17
+ /** Wrap a payload with the `schema_version` envelope. */
18
+ export function withSchema(name, payload) {
19
+ return { schema_version: SCHEMA_VERSIONS[name], ...payload };
20
+ }
@@ -15,8 +15,18 @@ export interface AnalysisVerdict {
15
15
  summary: string;
16
16
  issues: AnalysisIssue[];
17
17
  reviewUrl: string;
18
- /** Whether the PR was auto-merged after analysis */
18
+ /**
19
+ * Whether the PR has actually been merged on GitHub. Read from the GH
20
+ * REST PR endpoint — NOT inferred from review policy / canAutoMerge /
21
+ * haystackRating, all of which mean "eligible", not "merged".
22
+ */
19
23
  autoMerged?: boolean;
24
+ /**
25
+ * Whether the rating synthesis flagged that a human reviewer is required
26
+ * (review policy, security-sensitive paths, etc.). Distinct from
27
+ * `state === 'needs-input'` — analysis can be clean AND still need a human.
28
+ */
29
+ needsHumanReview?: boolean;
20
30
  }
21
31
  export interface AnalysisIssue {
22
32
  file: string;
@@ -55,7 +65,7 @@ export interface HumanReviewReason {
55
65
  }
56
66
  export interface RatingSynthesis {
57
67
  haystackRating: 1 | 2 | 3 | 4 | 5;
58
- analysisVerdict?: 'clean' | 'has-issues';
68
+ analysisVerdict?: 'clean' | 'has-issues' | 'needs-review';
59
69
  synthesis: string;
60
70
  synthesisDisplay?: SynthesisDisplayIssue[];
61
71
  verifiedBugs: VerifiedBug[];
@@ -99,9 +109,6 @@ export type AnalysisReadyResult = {
99
109
  * Returns 'ready' if complete, 'pending' if not yet available, or 'error' for server failures.
100
110
  */
101
111
  export declare function checkAnalysisReady(owner: string, repo: string, prNumber: number, token?: string): Promise<AnalysisReadyResult>;
102
- /**
103
- * Fetch analysis results once complete. Combines bug detection and rule violations.
104
- */
105
112
  export declare function fetchAnalysisResults(owner: string, repo: string, prNumber: number, token?: string): Promise<AnalysisVerdict>;
106
113
  /**
107
114
  * Fetch the full rating synthesis for a PR.
@@ -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),