@haystackeditor/cli 0.15.17 → 0.15.19
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/README.md +45 -6
- package/dist/assets/skills/map-your-system.md +138 -0
- package/dist/commands/ask.d.ts +14 -0
- package/dist/commands/ask.js +20 -0
- package/dist/commands/inbox.d.ts +65 -0
- package/dist/commands/inbox.js +137 -0
- package/dist/commands/mcp.js +88 -134
- package/dist/commands/pr.d.ts +37 -1
- package/dist/commands/pr.js +95 -77
- package/dist/commands/review.d.ts +24 -0
- package/dist/commands/review.js +193 -0
- package/dist/commands/schema-cmd.js +1 -1
- package/dist/commands/skills.js +4 -0
- package/dist/commands/traces.d.ts +48 -0
- package/dist/commands/traces.js +92 -0
- package/dist/commands/triage.d.ts +12 -0
- package/dist/commands/triage.js +90 -58
- package/dist/commands/webhooks.js +3 -6
- package/dist/index.js +79 -148
- package/dist/schema.d.ts +5 -2
- package/dist/schema.js +5 -2
- package/dist/utils/analysis-api.d.ts +40 -0
- package/dist/utils/analysis-api.js +108 -3
- package/dist/utils/haystack-api.d.ts +20 -0
- package/dist/utils/haystack-api.js +66 -0
- package/package.json +4 -4
- package/schemas/ask.v1.json +40 -0
- package/schemas/inbox.v1.json +27 -0
- package/schemas/pr.v2.json +54 -0
- package/schemas/traces.v1.json +53 -0
- package/schemas/triage.v2.json +64 -0
- package/dist/assets/skills/install-verification.md +0 -67
- package/dist/commands/verification.d.ts +0 -21
- package/dist/commands/verification.js +0 -291
- package/dist/verification/contract.d.ts +0 -240
- package/dist/verification/contract.js +0 -115
- package/dist/verification/init.d.ts +0 -10
- package/dist/verification/init.js +0 -243
- package/dist/verification/manifest.d.ts +0 -49
- package/dist/verification/manifest.js +0 -186
- package/dist/verification/runner.d.ts +0 -20
- package/dist/verification/runner.js +0 -102
- package/dist/verification/safety.d.ts +0 -10
- package/dist/verification/safety.js +0 -252
- package/dist/verification/validate.d.ts +0 -12
- package/dist/verification/validate.js +0 -219
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review command — Trigger a fresh Haystack analysis for a PR.
|
|
3
|
+
*
|
|
4
|
+
* haystack review [pr] [--no-wait]
|
|
5
|
+
*
|
|
6
|
+
* Haystack runs a full analysis when a PR is opened (or marked ready);
|
|
7
|
+
* later pushes only get a resolution check against the existing findings.
|
|
8
|
+
* This command is the explicit escape hatch: it re-runs full detection on
|
|
9
|
+
* the PR's current head — use it after substantial new commits, or any
|
|
10
|
+
* time you want a fresh second opinion.
|
|
11
|
+
*
|
|
12
|
+
* When called without a PR identifier, reads the last submitted PR from
|
|
13
|
+
* `.haystack/pending-submit.json` (written by `haystack submit`).
|
|
14
|
+
*
|
|
15
|
+
* Accepts a PR identifier in several formats:
|
|
16
|
+
* haystack review # Last submitted PR
|
|
17
|
+
* haystack review 123 # Uses current repo's owner/repo
|
|
18
|
+
* haystack review owner/repo#123 # Fully qualified
|
|
19
|
+
* haystack review https://github.com/owner/repo/pull/123
|
|
20
|
+
*/
|
|
21
|
+
import chalk from 'chalk';
|
|
22
|
+
import { triggerAnalysis, fetchTriggerStatus } from '../utils/analysis-api.js';
|
|
23
|
+
import { fetchTriageHeadSha } from './triage.js';
|
|
24
|
+
import { parseRemoteUrl } from '../utils/git.js';
|
|
25
|
+
import { loadToken } from '../utils/auth.js';
|
|
26
|
+
import { loadPendingSubmit } from '../utils/pending-state.js';
|
|
27
|
+
import { triageCommand } from './triage.js';
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Constants
|
|
30
|
+
// ============================================================================
|
|
31
|
+
const STATUS_POLL_INTERVAL_MS = 10_000;
|
|
32
|
+
// Full analyses regularly take 10+ minutes on large PRs; this bounds the
|
|
33
|
+
// CLI's wait, not the analysis itself (results land on the feed regardless).
|
|
34
|
+
const STATUS_POLL_TIMEOUT_MS = 25 * 60 * 1000;
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// PR identifier parsing (shared pattern with request-review.ts / triage.ts)
|
|
37
|
+
// ============================================================================
|
|
38
|
+
function parsePRIdentifier(identifier) {
|
|
39
|
+
const urlMatch = identifier.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
|
|
40
|
+
if (urlMatch) {
|
|
41
|
+
return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
|
|
42
|
+
}
|
|
43
|
+
const qualifiedMatch = identifier.match(/^([^/]+)\/([^#]+)#(\d+)$/);
|
|
44
|
+
if (qualifiedMatch) {
|
|
45
|
+
return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
|
|
46
|
+
}
|
|
47
|
+
const numberMatch = identifier.match(/^#?(\d+)$/);
|
|
48
|
+
if (numberMatch) {
|
|
49
|
+
const prNumber = parseInt(numberMatch[1], 10);
|
|
50
|
+
try {
|
|
51
|
+
const remote = parseRemoteUrl('origin');
|
|
52
|
+
return { owner: remote.owner, repo: remote.repo, prNumber };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error(`Cannot determine repository for PR #${prNumber}.\n` +
|
|
56
|
+
`Use the full format: haystack review owner/repo#${prNumber}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Invalid PR identifier: "${identifier}"\n\n` +
|
|
60
|
+
`Accepted formats:\n` +
|
|
61
|
+
` haystack review 123\n` +
|
|
62
|
+
` haystack review owner/repo#123\n` +
|
|
63
|
+
` haystack review https://github.com/owner/repo/pull/123`);
|
|
64
|
+
}
|
|
65
|
+
function resolvePR(identifier) {
|
|
66
|
+
if (identifier) {
|
|
67
|
+
try {
|
|
68
|
+
return parsePRIdentifier(identifier);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error(chalk.red(`\n${err.message}\n`));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const pending = loadPendingSubmit();
|
|
76
|
+
if (!pending) {
|
|
77
|
+
console.log(chalk.dim('No pending submits. Pass a PR number to review a specific PR.\n'));
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return { owner: pending.owner, repo: pending.repo, prNumber: pending.prNumber };
|
|
81
|
+
}
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Waiting
|
|
84
|
+
// ============================================================================
|
|
85
|
+
async function waitForRun(requestId, token) {
|
|
86
|
+
const deadline = Date.now() + STATUS_POLL_TIMEOUT_MS;
|
|
87
|
+
let consecutiveErrors = 0;
|
|
88
|
+
while (Date.now() < deadline) {
|
|
89
|
+
const result = await fetchTriggerStatus(requestId, token);
|
|
90
|
+
if (result.status === 'COMPLETED')
|
|
91
|
+
return 'completed';
|
|
92
|
+
if (result.status === 'FAILED')
|
|
93
|
+
return 'failed';
|
|
94
|
+
if (result.status === 'SUPERSEDED')
|
|
95
|
+
return 'superseded';
|
|
96
|
+
if (result.status === 'error') {
|
|
97
|
+
consecutiveErrors++;
|
|
98
|
+
if (consecutiveErrors >= 5)
|
|
99
|
+
return 'failed';
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
consecutiveErrors = 0;
|
|
103
|
+
}
|
|
104
|
+
process.stderr.write(`\r ${chalk.dim('Analysis in progress...')}${' '.repeat(5)}`);
|
|
105
|
+
// Deliberately NOT unref'd: during the wait this timer can be the only
|
|
106
|
+
// live handle, and an unref'd timer lets Node exit mid-wait.
|
|
107
|
+
await new Promise((resolve) => {
|
|
108
|
+
setTimeout(resolve, STATUS_POLL_INTERVAL_MS);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return 'timeout';
|
|
112
|
+
}
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// Main command
|
|
115
|
+
// ============================================================================
|
|
116
|
+
export async function reviewCommand(identifier, options) {
|
|
117
|
+
const pr = resolvePR(identifier);
|
|
118
|
+
if (!pr)
|
|
119
|
+
return;
|
|
120
|
+
const prLabel = `${pr.owner}/${pr.repo}#${pr.prNumber}`;
|
|
121
|
+
let token = null;
|
|
122
|
+
try {
|
|
123
|
+
token = await loadToken({ owner: pr.owner, repo: pr.repo });
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.error(chalk.red(`\nCould not load Haystack credentials: ${err.message}\n`));
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
if (!token) {
|
|
130
|
+
console.error(chalk.red('\nTriggering an analysis requires authentication.'));
|
|
131
|
+
console.log(chalk.dim(' Run ') + chalk.cyan('haystack login') + chalk.dim(' first.\n'));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
console.log(chalk.dim(`\nRequesting a fresh analysis for ${prLabel}...`));
|
|
135
|
+
// Resolve the current head so ingestion's dedup compares commits — an
|
|
136
|
+
// in-flight run for an OLDER head must be superseded, not reused. A
|
|
137
|
+
// failed metadata fetch degrades to a head-less trigger (the dedup is a
|
|
138
|
+
// freshness aid, not a correctness requirement) instead of crashing.
|
|
139
|
+
let headSha;
|
|
140
|
+
try {
|
|
141
|
+
headSha = await fetchTriageHeadSha(pr, token) ?? undefined;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
console.log(chalk.dim(` Could not resolve the PR head (${err.message}) — continuing without it.`));
|
|
145
|
+
}
|
|
146
|
+
const trigger = await triggerAnalysis(pr.owner, pr.repo, pr.prNumber, token, headSha);
|
|
147
|
+
if (trigger.status === 'rate_limited') {
|
|
148
|
+
console.error(chalk.red(`\n ${trigger.message}`));
|
|
149
|
+
if (trigger.retryAfterSeconds) {
|
|
150
|
+
console.log(chalk.dim(` Try again in ~${Math.ceil(trigger.retryAfterSeconds / 60)} minute(s).\n`));
|
|
151
|
+
}
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
if (trigger.status === 'denied') {
|
|
155
|
+
console.error(chalk.red(`\n ${trigger.message}\n`));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
if (trigger.status === 'error') {
|
|
159
|
+
console.error(chalk.red(`\n Failed to trigger analysis: ${trigger.message}\n`));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
if (trigger.status === 'already_processing') {
|
|
163
|
+
console.log(chalk.yellow(' An analysis for this PR is already running — waiting on it instead.'));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
console.log(chalk.green(' Analysis started.'));
|
|
167
|
+
}
|
|
168
|
+
if (options.wait === false) {
|
|
169
|
+
console.log(chalk.dim(' Check results later with: ') + chalk.cyan(`haystack triage ${prLabel}`));
|
|
170
|
+
console.log(chalk.dim(' Or view the review: ') + chalk.cyan(`https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}\n`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const outcome = await waitForRun(trigger.requestId, token);
|
|
174
|
+
process.stderr.write('\r' + ' '.repeat(60) + '\r');
|
|
175
|
+
if (outcome === 'failed') {
|
|
176
|
+
console.error(chalk.red('\n Analysis run failed.'));
|
|
177
|
+
console.log(chalk.dim(' Check the review page: ') + chalk.cyan(`https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}\n`));
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
if (outcome === 'superseded') {
|
|
181
|
+
console.log(chalk.yellow('\n This run was superseded by a newer analysis (a push likely landed meanwhile).'));
|
|
182
|
+
console.log(chalk.dim(' Showing the latest results instead.'));
|
|
183
|
+
await triageCommand(prLabel, { wait: false });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (outcome === 'timeout') {
|
|
187
|
+
console.log(chalk.yellow('\n Still running after 25 minutes — giving up on waiting.'));
|
|
188
|
+
console.log(chalk.dim(' Check results later with: ') + chalk.cyan(`haystack triage ${prLabel}\n`));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Completed — render results through the triage view (no extra wait).
|
|
192
|
+
await triageCommand(prLabel, { wait: false });
|
|
193
|
+
}
|
|
@@ -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>.
|
|
40
|
+
`External URL: ${chalk.dim('https://docs.haystackeditor.com/cli/schemas/<name>.v<major>.json')}`);
|
|
41
41
|
}
|
package/dist/commands/skills.js
CHANGED
|
@@ -15,6 +15,7 @@ function getSkillsSourceDir() {
|
|
|
15
15
|
const SKILL_FILES = [
|
|
16
16
|
'setup-haystack.md',
|
|
17
17
|
'submit.md',
|
|
18
|
+
'map-your-system.md',
|
|
18
19
|
];
|
|
19
20
|
function installSkillFiles(targetDir) {
|
|
20
21
|
const sourceDir = getSkillsSourceDir();
|
|
@@ -55,6 +56,7 @@ export async function installSkills(options) {
|
|
|
55
56
|
}
|
|
56
57
|
const claudeCommandPath = join(claudeCommandsDir, 'setup-haystack.md');
|
|
57
58
|
writeFileSync(claudeCommandPath, '# Set Up Haystack\n\nFollow .agents/skills/setup-haystack.md to set up Haystack for this repo.\n');
|
|
59
|
+
writeFileSync(join(claudeCommandsDir, 'map-your-system.md'), '# Map Your System for Haystack QA\n\nFollow .agents/skills/map-your-system.md to create .haystack/system.yml — verified facts about how this system builds, boots, seeds data, and logs in, so Haystack QA can run it.\n');
|
|
58
60
|
showSuccessMessage();
|
|
59
61
|
}
|
|
60
62
|
function showSuccessMessage() {
|
|
@@ -62,6 +64,7 @@ function showSuccessMessage() {
|
|
|
62
64
|
console.log(chalk.white('Available skills:'));
|
|
63
65
|
console.log(chalk.cyan(' /setup-haystack ') + chalk.gray('- Create .haystack.json with AI assistance'));
|
|
64
66
|
console.log(chalk.cyan(' /submit ') + chalk.gray('- Submit a PR via Haystack'));
|
|
67
|
+
console.log(chalk.cyan(' /map-your-system ') + chalk.gray('- Write .haystack/system.yml so Haystack QA can run this system'));
|
|
65
68
|
console.log();
|
|
66
69
|
console.log(chalk.white('Run in your coding CLI:'));
|
|
67
70
|
console.log(chalk.cyan(' /setup-haystack'));
|
|
@@ -71,5 +74,6 @@ export async function listSkills() {
|
|
|
71
74
|
console.log(chalk.white('\nAvailable Haystack skills:\n'));
|
|
72
75
|
console.log(chalk.cyan(' /setup-haystack ') + chalk.gray('- Master setup - creates .haystack.json'));
|
|
73
76
|
console.log(chalk.cyan(' /submit ') + chalk.gray('- Submit a PR via Haystack'));
|
|
77
|
+
console.log(chalk.cyan(' /map-your-system ') + chalk.gray('- Write .haystack/system.yml so Haystack QA can run this system'));
|
|
74
78
|
console.log();
|
|
75
79
|
}
|
|
@@ -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 {};
|
package/dist/commands/triage.js
CHANGED
|
@@ -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
|
-
//
|
|
89
|
+
// Alpha output stays intentionally small. Findings remain fully visible below.
|
|
89
90
|
if (manifest && (manifest.fixedItems.length > 0 || manifest.leftForReview.length > 0)) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
//
|
|
107
|
-
|
|
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
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
source: f.source
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
34
|
-
//
|
|
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'
|
|
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(', ')}`);
|