@haystackeditor/cli 0.15.16 → 0.15.18

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 (42) hide show
  1. package/README.md +45 -6
  2. package/dist/commands/ask.d.ts +14 -0
  3. package/dist/commands/ask.js +20 -0
  4. package/dist/commands/inbox.d.ts +65 -0
  5. package/dist/commands/inbox.js +137 -0
  6. package/dist/commands/mcp.js +88 -134
  7. package/dist/commands/pr.d.ts +37 -1
  8. package/dist/commands/pr.js +95 -77
  9. package/dist/commands/schema-cmd.js +1 -1
  10. package/dist/commands/traces.d.ts +48 -0
  11. package/dist/commands/traces.js +92 -0
  12. package/dist/commands/triage.d.ts +12 -0
  13. package/dist/commands/triage.js +90 -58
  14. package/dist/commands/webhooks.js +3 -6
  15. package/dist/index.js +50 -148
  16. package/dist/schema.d.ts +5 -2
  17. package/dist/schema.js +5 -2
  18. package/dist/triage/prompts.js +4 -0
  19. package/dist/utils/analysis-api.d.ts +7 -0
  20. package/dist/utils/haystack-api.d.ts +20 -0
  21. package/dist/utils/haystack-api.js +66 -0
  22. package/package.json +4 -4
  23. package/schemas/ask.v1.json +40 -0
  24. package/schemas/inbox.v1.json +27 -0
  25. package/schemas/pr.v2.json +54 -0
  26. package/schemas/traces.v1.json +53 -0
  27. package/schemas/triage.v2.json +64 -0
  28. package/dist/assets/skills/install-verification.md +0 -67
  29. package/dist/commands/verification.d.ts +0 -21
  30. package/dist/commands/verification.js +0 -291
  31. package/dist/verification/contract.d.ts +0 -240
  32. package/dist/verification/contract.js +0 -115
  33. package/dist/verification/init.d.ts +0 -10
  34. package/dist/verification/init.js +0 -243
  35. package/dist/verification/manifest.d.ts +0 -49
  36. package/dist/verification/manifest.js +0 -186
  37. package/dist/verification/runner.d.ts +0 -20
  38. package/dist/verification/runner.js +0 -102
  39. package/dist/verification/safety.d.ts +0 -10
  40. package/dist/verification/safety.js +0 -252
  41. package/dist/verification/validate.d.ts +0 -12
  42. package/dist/verification/validate.js +0 -219
@@ -1,103 +1,121 @@
1
- /**
2
- * PR-reference helpers shared by the MCP server's `pr_state` tool.
3
- *
4
- * No top-level `haystack pr` CLI command — the original `pr <ref>` /
5
- * `pr <ref> merge|snooze|dismiss` surface was scope creep on the
6
- * CLI-as-platform spec. Use the pre-existing `haystack pr-status <pr>`
7
- * for the same read.
8
- *
9
- * Output shape is stamped with `schema_version` per `schemas/pr.v1.json`.
10
- */
11
1
  import chalk from 'chalk';
12
2
  import { loadToken } from '../utils/auth.js';
13
- import { fetchRatingSynthesis } from '../utils/analysis-api.js';
3
+ import { fetchAutoFixManifest, fetchRatingSynthesis } from '../utils/analysis-api.js';
14
4
  import { parseRemoteUrl } from '../utils/git.js';
5
+ import { fetchAnalysisArtifact, fetchGitHubJson, haystackJson } from '../utils/haystack-api.js';
15
6
  import { withSchema } from '../schema.js';
16
- const GITHUB_PROXY = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
17
7
  export function parsePrRef(ref) {
18
8
  const urlMatch = ref.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
19
9
  if (urlMatch)
20
- return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
10
+ return { owner: urlMatch[1], repo: urlMatch[2], prNumber: Number.parseInt(urlMatch[3], 10) };
21
11
  const qualifiedMatch = ref.match(/^([^/]+)\/([^#]+)#(\d+)$/);
22
12
  if (qualifiedMatch)
23
- return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
13
+ return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: Number.parseInt(qualifiedMatch[3], 10) };
24
14
  const bare = ref.match(/^#?(\d+)$/);
25
15
  if (bare) {
26
- const r = parseRemoteUrl('origin');
27
- return { owner: r.owner, repo: r.repo, prNumber: parseInt(bare[1], 10) };
16
+ const remote = parseRemoteUrl('origin');
17
+ return { owner: remote.owner, repo: remote.repo, prNumber: Number.parseInt(bare[1], 10) };
28
18
  }
29
19
  throw new Error(`Invalid PR reference: "${ref}". Use 123, owner/repo#123, or a GitHub URL.`);
30
20
  }
31
- async function ghPr(token, owner, repo, num) {
32
- const url = `${GITHUB_PROXY}/api/github/repos/${owner}/${repo}/pulls/${num}`;
33
- const res = await fetch(url, {
34
- headers: {
35
- Accept: 'application/vnd.github+json',
36
- Authorization: `Bearer ${token}`,
37
- 'User-Agent': 'Haystack-CLI',
38
- },
39
- });
40
- if (!res.ok) {
41
- throw new Error(`GitHub API ${res.status}: ${(await res.text()).slice(0, 200)}`);
21
+ export function mergePublicState(meta, entry, queueAvailable = true) {
22
+ if (meta.merged) {
23
+ return { status: 'merged', reason: 'The PR has merged.', blocking_checks: [], action: null };
24
+ }
25
+ if (meta.state === 'closed') {
26
+ return { status: 'closed', reason: 'The PR was closed without merging.', blocking_checks: [], action: null };
27
+ }
28
+ if (!queueAvailable) {
29
+ return {
30
+ status: 'unavailable',
31
+ reason: 'Haystack merge status is temporarily unavailable.',
32
+ blocking_checks: [],
33
+ action: 'Retry this request before making a merge decision.',
34
+ };
35
+ }
36
+ if (!entry) {
37
+ return {
38
+ status: 'not_applicable',
39
+ reason: 'The PR is not currently in Haystack merge processing.',
40
+ blocking_checks: [],
41
+ action: null,
42
+ };
42
43
  }
43
- return res.json();
44
+ const failed = entry.failedChecks ?? [];
45
+ const blocked = failed.length > 0
46
+ || entry.status === 'failed'
47
+ || entry.status === 'needs_shepherding'
48
+ || entry.status === 'awaiting_human_approval';
49
+ return {
50
+ status: blocked ? 'blocked' : 'processing',
51
+ reason: blocked ? 'The PR has merge blockers.' : 'Haystack is processing the PR.',
52
+ blocking_checks: failed,
53
+ action: blocked ? 'Resolve the merge blockers.' : null,
54
+ };
44
55
  }
45
- export async function prReadCommand(ref, opts) {
46
- const pr = parsePrRef(ref);
47
- const token = await loadToken();
48
- if (!token) {
49
- console.error(chalk.red('Not authenticated. Run `haystack login` first.'));
50
- process.exit(1);
56
+ export async function fetchMergePublicState(meta, prNumber, owner, repo, token) {
57
+ try {
58
+ const mergeQueue = await haystackJson(`/api/merge-queue/status?repo=${encodeURIComponent(`${owner}/${repo}`)}&branch=${encodeURIComponent(meta.base.ref)}`, token);
59
+ return mergePublicState(meta, mergeQueue.prStatuses?.[String(prNumber)]);
51
60
  }
52
- const [meta, synthesis] = await Promise.all([
53
- ghPr(token, pr.owner, pr.repo, pr.prNumber),
54
- // 404 → null is the "not analyzed yet" signal; we still log unexpected
55
- // failures so they don't look the same as "not analyzed".
56
- fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token).catch((err) => {
57
- console.error(chalk.dim(`[pr] rating fetch failed: ${err instanceof Error ? err.message : String(err)}`));
58
- return null;
59
- }),
60
- ]);
61
- let bucket = 'analyzing';
62
- if (meta.merged)
63
- bucket = 'merged';
64
- else if (meta.state === 'closed')
65
- bucket = 'merged';
66
- else if (synthesis?.haystackRating != null) {
67
- bucket = synthesis.haystackRating <= 2
68
- ? 'issues'
69
- : synthesis.haystackRating === 5
70
- ? 'good-to-merge'
71
- : 'needs-attention';
61
+ catch {
62
+ return mergePublicState(meta, undefined, false);
72
63
  }
64
+ }
65
+ export async function fetchPrStatePayload(ref) {
66
+ const pr = parsePrRef(ref);
67
+ const token = await loadToken({ owner: pr.owner, repo: pr.repo });
68
+ if (!token)
69
+ throw new Error('Not authenticated. Run `haystack login` first.');
70
+ const meta = await fetchGitHubJson(`/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`, token);
71
+ const [synthesis, manifest, traces, merge] = await Promise.all([
72
+ fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token),
73
+ fetchAutoFixManifest(pr.owner, pr.repo, pr.prNumber, token),
74
+ fetchAnalysisArtifact(pr.owner, pr.repo, pr.prNumber, 'entire_traces.json', token),
75
+ fetchMergePublicState(meta, pr.prNumber, pr.owner, pr.repo, token),
76
+ ]);
77
+ const verdict = synthesis?.analysisVerdict
78
+ ?? (synthesis ? (synthesis.haystackRating >= 5 ? 'clean' : 'has-issues') : null);
73
79
  const payload = {
74
- owner: pr.owner,
75
- repo: pr.repo,
76
- prNumber: pr.prNumber,
77
- bucket,
78
- state: meta.merged ? 'merged' : meta.state,
80
+ ref: `${pr.owner}/${pr.repo}#${pr.prNumber}`,
79
81
  title: meta.title,
80
- headSha: meta.head.sha,
81
- haystackUrl: `https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}`,
82
- mergeQueue: null,
83
- lastTriage: synthesis?.haystackRating != null
82
+ head_sha: meta.head.sha,
83
+ triage: synthesis
84
84
  ? {
85
- rating: synthesis.haystackRating,
86
- completedAt: new Date().toISOString(),
87
- headSha: meta.head.sha,
85
+ status: 'complete',
86
+ verdict: verdict === 'clean' ? 'good_to_merge' : verdict === 'needs-review' ? 'needs_review' : 'needs_input',
87
+ findings_count: synthesis.synthesisDisplay?.length ?? 0,
88
88
  }
89
- : null,
89
+ : { status: 'pending', verdict: null, findings_count: 0 },
90
+ merge,
91
+ traces: {
92
+ available: traces?.hasTraces === true,
93
+ count: traces?.traces?.length ?? 0,
94
+ },
90
95
  };
91
- if (opts.json) {
92
- console.log(JSON.stringify(withSchema('pr', payload), null, 2));
93
- return;
96
+ if (manifest) {
97
+ const fixed = manifest.fixedItems.length;
98
+ const remaining = manifest.leftForReview.length;
99
+ payload.fixer = {
100
+ status: 'completed',
101
+ result_sha: manifest.resultSha ?? meta.head.sha,
102
+ summary: `Fixed ${fixed} finding${fixed === 1 ? '' : 's'}; ${remaining} remain${remaining === 1 ? 's' : ''} for review.`,
103
+ };
94
104
  }
95
- console.log(`\n ${chalk.bold(`${pr.owner}/${pr.repo}#${pr.prNumber}`)} ${chalk.dim(meta.title)}`);
96
- console.log(` ${chalk.dim('bucket:')} ${bucket}`);
97
- console.log(` ${chalk.dim('state:')} ${payload.state}`);
98
- console.log(` ${chalk.dim('head:')} ${meta.head.sha.slice(0, 7)} (${meta.head.ref})`);
99
- if (payload.lastTriage) {
100
- console.log(` ${chalk.dim('rating:')} ★${payload.lastTriage.rating}`);
105
+ return withSchema('pr', payload);
106
+ }
107
+ export async function prReadCommand(ref, options) {
108
+ const payload = await fetchPrStatePayload(ref);
109
+ if (options.json) {
110
+ console.log(JSON.stringify(payload, null, 2));
111
+ return;
101
112
  }
102
- console.log(` ${chalk.dim(payload.haystackUrl)}\n`);
113
+ const triage = payload.triage;
114
+ const merge = payload.merge;
115
+ console.log(`\n ${chalk.bold(String(payload.ref))} ${chalk.dim(String(payload.title))}`);
116
+ console.log(` ${chalk.dim('triage:')} ${triage.status}${triage.verdict ? ` · ${triage.verdict}` : ''} · ${triage.findings_count} findings`);
117
+ console.log(` ${chalk.dim('merge:')} ${merge.status} · ${merge.reason}`);
118
+ if (merge.action)
119
+ console.log(` ${chalk.dim('next:')} ${merge.action}`);
120
+ console.log('');
103
121
  }
@@ -37,5 +37,5 @@ export function listSchemas() {
37
37
  console.log(` ${chalk.cyan(name.padEnd(10))} v${SCHEMA_VERSIONS[name]}`);
38
38
  }
39
39
  console.log(`\nPrint one with: ${chalk.dim('haystack schema <name>')}\n` +
40
- `External URL: ${chalk.dim('https://docs.haystackeditor.com/cli/schemas/<name>.v1.json')}`);
40
+ `External URL: ${chalk.dim('https://docs.haystackeditor.com/cli/schemas/<name>.v<major>.json')}`);
41
41
  }
@@ -0,0 +1,48 @@
1
+ export interface TraceGetOptions {
2
+ json?: boolean;
3
+ cursor?: string;
4
+ limit?: string;
5
+ }
6
+ export declare function fetchTracesListPayload(ref: string): Promise<{
7
+ operation: string;
8
+ ref: string;
9
+ head_sha: string;
10
+ checkpoints: {
11
+ checkpoint_id: string;
12
+ commit_sha: string;
13
+ commit_message: string;
14
+ timestamp: string | null;
15
+ summary: string;
16
+ user_prompt: string | null;
17
+ files_touched: string[];
18
+ branch: string | null;
19
+ }[];
20
+ } & {
21
+ schema_version: string;
22
+ }>;
23
+ export declare function tracesListCommand(ref: string, options: {
24
+ json?: boolean;
25
+ }): Promise<void>;
26
+ export declare function fetchTracesGetPayload(ref: string, checkpointId: string, options: Pick<TraceGetOptions, 'cursor' | 'limit'>): Promise<{
27
+ operation: string;
28
+ ref: string;
29
+ head_sha: string;
30
+ checkpoint_id: string;
31
+ checkpoint: {
32
+ checkpoint_id: string;
33
+ commit_sha: string;
34
+ commit_message: string;
35
+ timestamp: string | null;
36
+ summary: string;
37
+ user_prompt: string | null;
38
+ files_touched: string[];
39
+ branch: string | null;
40
+ } | null;
41
+ chunks: string[];
42
+ total_chunks: number;
43
+ total_chars: number;
44
+ next_cursor: string | null;
45
+ } & {
46
+ schema_version: string;
47
+ }>;
48
+ export declare function tracesGetCommand(ref: string, checkpointId: string, options: TraceGetOptions): Promise<void>;
@@ -0,0 +1,92 @@
1
+ import chalk from 'chalk';
2
+ import { loadToken } from '../utils/auth.js';
3
+ import { fetchAnalysisArtifact, fetchGitHubJson } from '../utils/haystack-api.js';
4
+ import { withSchema } from '../schema.js';
5
+ import { parsePrRef } from './pr.js';
6
+ function publicCheckpoint(trace) {
7
+ return {
8
+ checkpoint_id: trace.checkpointId,
9
+ commit_sha: trace.commitSha,
10
+ commit_message: trace.commitMessage,
11
+ timestamp: trace.timestamp ?? null,
12
+ summary: trace.overviewSummary,
13
+ user_prompt: trace.userPrompt ?? null,
14
+ files_touched: trace.filesTouched ?? [],
15
+ branch: trace.branch ?? null,
16
+ };
17
+ }
18
+ async function tokenAndHead(ref) {
19
+ const pr = parsePrRef(ref);
20
+ const token = await loadToken({ owner: pr.owner, repo: pr.repo });
21
+ if (!token)
22
+ throw new Error('Not authenticated. Run `haystack login` first.');
23
+ const meta = await fetchGitHubJson(`/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`, token);
24
+ return { pr, token, headSha: meta.head.sha, ref: `${pr.owner}/${pr.repo}#${pr.prNumber}` };
25
+ }
26
+ export async function fetchTracesListPayload(ref) {
27
+ const { pr, token, headSha, ref: normalizedRef } = await tokenAndHead(ref);
28
+ const overview = await fetchAnalysisArtifact(pr.owner, pr.repo, pr.prNumber, 'entire_traces.json', token);
29
+ return withSchema('traces', {
30
+ operation: 'list',
31
+ ref: normalizedRef,
32
+ head_sha: headSha,
33
+ checkpoints: (overview?.traces ?? []).map(publicCheckpoint),
34
+ });
35
+ }
36
+ export async function tracesListCommand(ref, options) {
37
+ const payload = await fetchTracesListPayload(ref);
38
+ if (options.json) {
39
+ console.log(JSON.stringify(payload, null, 2));
40
+ return;
41
+ }
42
+ if (payload.checkpoints.length === 0) {
43
+ console.log(chalk.dim('\nNo retained customer traces for this PR.\n'));
44
+ return;
45
+ }
46
+ console.log(chalk.bold('\nCustomer traces\n'));
47
+ for (const trace of payload.checkpoints) {
48
+ console.log(`${chalk.cyan(trace.checkpoint_id)} ${trace.commit_message}`);
49
+ console.log(` ${trace.summary}\n`);
50
+ }
51
+ }
52
+ export async function fetchTracesGetPayload(ref, checkpointId, options) {
53
+ const { pr, token, headSha, ref: normalizedRef } = await tokenAndHead(ref);
54
+ const [trace, overview] = await Promise.all([
55
+ fetchAnalysisArtifact(pr.owner, pr.repo, pr.prNumber, `entire_trace_chunks/${encodeURIComponent(checkpointId)}.json`, token),
56
+ fetchAnalysisArtifact(pr.owner, pr.repo, pr.prNumber, 'entire_traces.json', token),
57
+ ]);
58
+ if (!trace)
59
+ throw new Error(`Trace checkpoint not found: ${checkpointId}`);
60
+ if (trace.checkpointId !== checkpointId)
61
+ throw new Error('Trace checkpoint provenance mismatch');
62
+ const cursor = Math.max(0, Number.parseInt(options.cursor ?? '0', 10) || 0);
63
+ const limit = Math.min(100, Math.max(1, Number.parseInt(options.limit ?? '20', 10) || 20));
64
+ const chunks = trace.chunks.slice(cursor, cursor + limit);
65
+ const next = cursor + chunks.length < trace.chunks.length ? String(cursor + chunks.length) : null;
66
+ return withSchema('traces', {
67
+ operation: 'get',
68
+ ref: normalizedRef,
69
+ head_sha: headSha,
70
+ checkpoint_id: trace.checkpointId,
71
+ checkpoint: (() => {
72
+ const item = overview?.traces?.find((candidate) => candidate.checkpointId === trace.checkpointId);
73
+ return item ? publicCheckpoint(item) : null;
74
+ })(),
75
+ chunks,
76
+ total_chunks: trace.chunks.length,
77
+ total_chars: trace.totalChars,
78
+ next_cursor: next,
79
+ });
80
+ }
81
+ export async function tracesGetCommand(ref, checkpointId, options) {
82
+ const payload = await fetchTracesGetPayload(ref, checkpointId, options);
83
+ const chunks = payload.chunks;
84
+ const next = payload.next_cursor;
85
+ if (options.json) {
86
+ console.log(JSON.stringify(payload, null, 2));
87
+ return;
88
+ }
89
+ console.log(chunks.join('\n\n---\n\n'));
90
+ if (next)
91
+ console.log(chalk.dim(`\nMore available: --cursor ${next}\n`));
92
+ }
@@ -19,10 +19,22 @@
19
19
  * --json Machine-readable JSON
20
20
  * (default) Rich terminal output
21
21
  */
22
+ import type { RatingSynthesis, AutoFixManifest } from '../utils/analysis-api.js';
22
23
  export interface TriageOptions {
23
24
  json?: boolean;
24
25
  wait?: boolean;
25
26
  hook?: boolean;
26
27
  clear?: boolean;
27
28
  }
29
+ interface ParsedPR {
30
+ owner: string;
31
+ repo: string;
32
+ prNumber: number;
33
+ }
34
+ export declare function buildJsonOutput(pr: ParsedPR, headSha: string | null, synthesis: RatingSynthesis, manifest?: AutoFixManifest | null): Record<string, unknown>;
35
+ export declare function buildJsonState(pr: ParsedPR, status: 'pending' | 'failed' | 'unavailable', message?: string): Record<string, unknown>;
36
+ export declare function fetchTriageHeadSha(pr: ParsedPR, token?: string): Promise<string | null>;
28
37
  export declare function triageCommand(identifier: string | undefined, options: TriageOptions): Promise<void>;
38
+ /** Pure read path for MCP: returns a public payload without writing stdout or exiting. */
39
+ export declare function fetchTriagePayload(identifier: string): Promise<Record<string, unknown>>;
40
+ export {};
@@ -26,6 +26,7 @@ import { parseRemoteUrl } from '../utils/git.js';
26
26
  import { loadToken } from '../utils/auth.js';
27
27
  import { loadPendingSubmit, updatePendingSubmit, clearPendingSubmit } from '../utils/pending-state.js';
28
28
  import { withSchema } from '../schema.js';
29
+ import { fetchGitHubJson } from '../utils/haystack-api.js';
29
30
  // ============================================================================
30
31
  // PR identifier parsing
31
32
  // ============================================================================
@@ -85,29 +86,14 @@ function isAutoFixing(finding, manifest) {
85
86
  function printRichOutput(pr, synthesis, manifest) {
86
87
  const reviewUrl = `https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}`;
87
88
  console.log(`\n ${chalk.bold(`Haystack Triage: ${pr.owner}/${pr.repo}#${pr.prNumber}`)}\n`);
88
- // Auto-fixer status — what's being fixed vs what it skipped
89
+ // Alpha output stays intentionally small. Findings remain fully visible below.
89
90
  if (manifest && (manifest.fixedItems.length > 0 || manifest.leftForReview.length > 0)) {
90
- if (manifest.fixedItems.length > 0) {
91
- console.log(chalk.bold(' Auto-fixer is handling these (leave them alone):\n'));
92
- for (const item of manifest.fixedItems) {
93
- console.log(` - [${item.category}] ${item.summary}`);
94
- }
95
- console.log(` ${chalk.dim("Don't fix these. The auto-fixer is already on them and will push its own commit.")}\n`);
96
- }
97
- if (manifest.leftForReview.length > 0) {
98
- console.log(chalk.bold(' Auto-fixer skipped these (they need you):\n'));
99
- for (const item of manifest.leftForReview) {
100
- console.log(` - [${item.category}] ${item.summary}`);
101
- console.log(` ${chalk.dim(item.reason)}`);
102
- }
103
- console.log('');
104
- }
91
+ const fixed = manifest.fixedItems.length;
92
+ const remaining = manifest.leftForReview.length;
93
+ console.log(chalk.dim(` Fixer: completed · fixed ${fixed}; ${remaining} remain for review.\n`));
105
94
  }
106
- // Findings — the actionable output for agents. Exclude anything the auto-fixer
107
- // is already handling so the agent doesn't duplicate (and fight) its work.
108
- const allFindings = synthesis.synthesisDisplay || [];
109
- const findings = allFindings.filter((f) => !isAutoFixing(f, manifest));
110
- const fixingCount = manifest?.fixedItems?.length ?? 0;
95
+ // Every customer-facing finding stays visible regardless of fixer state.
96
+ const findings = synthesis.synthesisDisplay || [];
111
97
  if (findings.length > 0) {
112
98
  console.log(chalk.bold(' Findings to address:\n'));
113
99
  for (let i = 0; i < findings.length; i++) {
@@ -129,35 +115,74 @@ function printRichOutput(pr, synthesis, manifest) {
129
115
  console.log('');
130
116
  }
131
117
  }
132
- else if (fixingCount > 0 && allFindings.length > 0) {
133
- // Every finding is being auto-fixed — nothing left for the agent.
134
- console.log(chalk.dim(' Nothing to address. Every finding is being auto-fixed (see above).\n'));
135
- }
136
118
  else {
137
119
  console.log(chalk.dim(' No findings.\n'));
138
120
  }
139
121
  console.log(` ${chalk.dim(reviewUrl)}\n`);
140
122
  }
141
- function printJsonOutput(pr, synthesis, manifest) {
142
- const output = withSchema('triage', {
143
- owner: pr.owner,
144
- repo: pr.repo,
145
- prNumber: pr.prNumber,
123
+ export function buildJsonOutput(pr, headSha, synthesis, manifest) {
124
+ const verdict = synthesis.analysisVerdict
125
+ ?? (synthesis.haystackRating >= 5 ? 'clean' : 'has-issues');
126
+ const payload = {
127
+ ref: `${pr.owner}/${pr.repo}#${pr.prNumber}`,
128
+ head_sha: headSha,
129
+ status: 'complete',
130
+ verdict: verdict === 'clean' ? 'good_to_merge' : verdict === 'needs-review' ? 'needs_review' : 'needs_input',
146
131
  rating: synthesis.haystackRating,
147
- // Issues the auto-fixer is already on — leave these alone, do not re-fix.
148
- autoFixerHandling: manifest?.fixedItems ?? [],
149
- autoFixerSkipped: manifest?.leftForReview ?? [],
132
+ human_review_reasons: synthesis.humanReviewReasons ?? [],
150
133
  findings: (synthesis.synthesisDisplay || []).map((f) => ({
134
+ id: f.id ?? null,
151
135
  category: f.category,
152
136
  summary: f.summary,
153
137
  detail: f.detail,
154
- agentFixPrompt: f.agentFixPrompt || null,
155
- source: f.source || null,
156
- // True when the auto-fixer is handling this finding — don't fix it yourself.
157
- autoFixing: isAutoFixing(f, manifest),
138
+ suggested_fix: f.agentFixPrompt ?? null,
139
+ source: f.source ?? null,
140
+ location: f.filePath || f.startLine != null || f.endLine != null
141
+ ? {
142
+ path: f.filePath ?? null,
143
+ start_line: f.startLine ?? null,
144
+ end_line: f.endLine ?? null,
145
+ }
146
+ : null,
147
+ severity: f.severity ?? null,
158
148
  })),
159
- });
160
- console.log(JSON.stringify(output, null, 2));
149
+ };
150
+ if (manifest) {
151
+ const fixed = manifest.fixedItems.length;
152
+ const remaining = manifest.leftForReview.length;
153
+ payload.fixer = {
154
+ status: 'completed',
155
+ result_sha: manifest.resultSha ?? headSha,
156
+ summary: `Fixed ${fixed} finding${fixed === 1 ? '' : 's'}; ${remaining} remain${remaining === 1 ? 's' : ''} for review.`,
157
+ };
158
+ }
159
+ return withSchema('triage', payload);
160
+ }
161
+ export function buildJsonState(pr, status, message) {
162
+ const payload = {
163
+ ref: `${pr.owner}/${pr.repo}#${pr.prNumber}`,
164
+ head_sha: null,
165
+ status,
166
+ verdict: null,
167
+ rating: null,
168
+ human_review_reasons: [],
169
+ findings: [],
170
+ };
171
+ if (message)
172
+ payload.message = message;
173
+ return withSchema('triage', payload);
174
+ }
175
+ export async function fetchTriageHeadSha(pr, token) {
176
+ if (!token)
177
+ return null;
178
+ const meta = await fetchGitHubJson(`/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`, token);
179
+ const headSha = meta.head?.sha;
180
+ if (!headSha)
181
+ throw new Error('GitHub PR metadata response is missing head.sha');
182
+ return headSha;
183
+ }
184
+ function printJsonState(pr, status, message) {
185
+ console.log(JSON.stringify(buildJsonState(pr, status, message), null, 2));
161
186
  }
162
187
  function ratingStars(rating) {
163
188
  const filled = '\u2605'; // ★
@@ -391,9 +416,7 @@ export async function triageCommand(identifier, options) {
391
416
  }
392
417
  else if (options.wait === false) {
393
418
  if (options.json) {
394
- console.log(JSON.stringify({
395
- owner: pr.owner, repo: pr.repo, prNumber: pr.prNumber, status: 'pending',
396
- }, null, 2));
419
+ printJsonState(pr, 'pending');
397
420
  }
398
421
  else {
399
422
  console.log(chalk.yellow('\n Analysis is still in progress.'));
@@ -415,9 +438,7 @@ export async function triageCommand(identifier, options) {
415
438
  }
416
439
  else if (waitResult === 'pending') {
417
440
  if (options.json) {
418
- console.log(JSON.stringify({
419
- owner: pr.owner, repo: pr.repo, prNumber: pr.prNumber, status: 'pending',
420
- }, null, 2));
441
+ printJsonState(pr, 'pending', 'Timed out waiting for analysis');
421
442
  }
422
443
  else {
423
444
  console.log(chalk.yellow('\n Timed out waiting for analysis.'));
@@ -428,9 +449,7 @@ export async function triageCommand(identifier, options) {
428
449
  else {
429
450
  // waitResult === 'error'
430
451
  if (options.json) {
431
- console.log(JSON.stringify({
432
- owner: pr.owner, repo: pr.repo, prNumber: pr.prNumber, status: 'error',
433
- }, null, 2));
452
+ printJsonState(pr, 'failed', 'Analysis check failed');
434
453
  }
435
454
  else {
436
455
  console.error(chalk.red('\n Analysis check failed.\n'));
@@ -446,10 +465,7 @@ export async function triageCommand(identifier, options) {
446
465
  return;
447
466
  }
448
467
  if (options.json) {
449
- console.log(JSON.stringify({
450
- owner: pr.owner, repo: pr.repo, prNumber: pr.prNumber,
451
- status: 'error', message: readyResult.message,
452
- }, null, 2));
468
+ printJsonState(pr, 'failed', readyResult.message);
453
469
  }
454
470
  else {
455
471
  console.error(chalk.red(`\n Analysis check failed: ${readyResult.message}\n`));
@@ -459,9 +475,10 @@ export async function triageCommand(identifier, options) {
459
475
  if (!analysisReady)
460
476
  return;
461
477
  // Fetch the full rating synthesis and auto-fix manifest in parallel
462
- const [synthesis, autoFixManifest] = await Promise.all([
478
+ const [synthesis, autoFixManifest, headSha] = await Promise.all([
463
479
  fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token),
464
480
  fetchAutoFixManifest(pr.owner, pr.repo, pr.prNumber, token),
481
+ fetchTriageHeadSha(pr, token),
465
482
  ]);
466
483
  // Update pending state if this PR matches the pending submit
467
484
  updatePendingSubmitIfExists(pr, synthesis ? 'completed' : undefined);
@@ -471,10 +488,7 @@ export async function triageCommand(identifier, options) {
471
488
  return;
472
489
  }
473
490
  if (options.json) {
474
- console.log(JSON.stringify({
475
- owner: pr.owner, repo: pr.repo, prNumber: pr.prNumber,
476
- status: 'error', message: 'Rating synthesis not available',
477
- }, null, 2));
491
+ printJsonState(pr, 'unavailable', 'Rating synthesis not available');
478
492
  }
479
493
  else {
480
494
  console.error(chalk.yellow('\n Analysis completed but rating synthesis is not available yet.'));
@@ -488,7 +502,7 @@ export async function triageCommand(identifier, options) {
488
502
  printHookOutput(pr, synthesis, autoFixManifest);
489
503
  }
490
504
  else if (options.json) {
491
- printJsonOutput(pr, synthesis, autoFixManifest);
505
+ console.log(JSON.stringify(buildJsonOutput(pr, headSha, synthesis, autoFixManifest), null, 2));
492
506
  }
493
507
  else {
494
508
  printRichOutput(pr, synthesis, autoFixManifest);
@@ -504,6 +518,24 @@ export async function triageCommand(identifier, options) {
504
518
  });
505
519
  }
506
520
  }
521
+ /** Pure read path for MCP: returns a public payload without writing stdout or exiting. */
522
+ export async function fetchTriagePayload(identifier) {
523
+ const pr = parsePRIdentifier(identifier);
524
+ const token = await loadToken({ owner: pr.owner, repo: pr.repo }) || undefined;
525
+ const ready = await checkAnalysisReady(pr.owner, pr.repo, pr.prNumber, token);
526
+ if (ready.status === 'pending')
527
+ return buildJsonState(pr, 'pending');
528
+ if (ready.status === 'error')
529
+ return buildJsonState(pr, 'failed', ready.message);
530
+ const [synthesis, manifest, headSha] = await Promise.all([
531
+ fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token),
532
+ fetchAutoFixManifest(pr.owner, pr.repo, pr.prNumber, token),
533
+ fetchTriageHeadSha(pr, token),
534
+ ]);
535
+ if (!synthesis)
536
+ return buildJsonState(pr, 'unavailable', 'Rating synthesis not available');
537
+ return buildJsonOutput(pr, headSha, synthesis, manifest);
538
+ }
507
539
  /**
508
540
  * Update the pending-submit state if the given PR matches the pending one.
509
541
  * Best-effort — failure is silently ignored.
@@ -30,14 +30,11 @@ import { parseRemoteUrl, UnsupportedRemoteUrlError } from '../utils/git.js';
30
30
  import { loadToken } from '../utils/auth.js';
31
31
  const DEFAULT_WORKER_URL = process.env.HAYSTACK_WEBHOOKS_URL ??
32
32
  'https://haystack-webhooks.akshaysg.workers.dev';
33
- // Keep in lockstep with infra/webhooks-worker/src/types.ts ALLOWED_EVENTS
34
- // and agent/cloudflare/src/webhook-emitter.ts WebhookEvent. The three
35
- // lists must agree: a CLI-accepted event with no worker/emitter support
36
- // either 400s at registration or silently never fires.
33
+ // Public events accepted by the CLI. The backend may support additional
34
+ // gated events, but alpha-only features should not appear in normal CLI help.
37
35
  const ALLOWED_EVENTS = [
38
36
  'triage.completed',
39
37
  'triage.failed',
40
- 'fixer.completed',
41
38
  'merge_queue.state_changed',
42
39
  // 'review_chat.response_ready' deferred — chat CLI was cut, no producer.
43
40
  'pr.classified',
@@ -124,7 +121,7 @@ async function readJsonOrFail(res) {
124
121
  // ─── register ────────────────────────────────────────────────────────────────
125
122
  export async function registerWebhook(opts) {
126
123
  const repo = opts.repo ?? currentRepo();
127
- const events = opts.events ?? ['triage.completed', 'triage.failed', 'fixer.completed'];
124
+ const events = opts.events ?? ['triage.completed', 'triage.failed'];
128
125
  for (const e of events) {
129
126
  if (!ALLOWED_EVENTS.includes(e)) {
130
127
  throw new Error(`Unknown event "${e}". Allowed: ${ALLOWED_EVENTS.join(', ')}`);