@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.
@@ -0,0 +1 @@
1
+ export declare function runMcpServer(): Promise<void>;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `haystack mcp` — Model Context Protocol server.
3
+ *
4
+ * Exposes Haystack's read surface to Claude Code, Cursor, Codex, etc. as
5
+ * stdio MCP tools. Every tool wraps the same library function that powers
6
+ * the corresponding `--json` CLI command, so an MCP client and a webhook
7
+ * receiver see byte-identical payloads.
8
+ *
9
+ * Tools:
10
+ * triage_pr(ref) same shape as `haystack triage <ref> --json`
11
+ * pr_state(ref) PR state in the Haystack pipeline (schemas/pr.v1.json)
12
+ * schema(name) prints the JSON Schema for a noun (debugging)
13
+ *
14
+ * Auth: inherited from the local CLI (loadToken). MCP clients run as the
15
+ * operator's user, so they get whatever credentials are on disk — same model
16
+ * as `git`, `gh`, etc.
17
+ */
18
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
21
+ import { readFileSync } from 'node:fs';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { dirname, join } from 'node:path';
24
+ import { parsePrRef, prReadCommand } from './pr.js';
25
+ import { fetchRatingSynthesis, fetchAutoFixManifest } from '../utils/analysis-api.js';
26
+ import { loadToken } from '../utils/auth.js';
27
+ import { SCHEMA_VERSIONS, withSchema } from '../schema.js';
28
+ const TOOLS = [
29
+ {
30
+ name: 'triage_pr',
31
+ description: 'Fetch the latest triage results for a pull request. Returns rating, findings, ' +
32
+ 'and which findings the auto-fixer is already handling. Identical shape to ' +
33
+ '`haystack triage <ref> --json`.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ ref: {
38
+ type: 'string',
39
+ description: 'PR reference: bare number, owner/repo#number, or GitHub URL.',
40
+ },
41
+ },
42
+ required: ['ref'],
43
+ },
44
+ },
45
+ {
46
+ name: 'pr_state',
47
+ description: 'Show the Haystack state of a single PR: bucket, last triage rating, merge state.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ ref: { type: 'string', description: 'PR reference.' },
52
+ },
53
+ required: ['ref'],
54
+ },
55
+ },
56
+ {
57
+ name: 'schema',
58
+ description: 'Return the JSON Schema for a Haystack --json command output. ' +
59
+ 'Use this to discover the exact shape of triage / setup / pr envelopes.',
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: {
63
+ name: {
64
+ type: 'string',
65
+ enum: Object.keys(SCHEMA_VERSIONS),
66
+ description: 'Schema noun.',
67
+ },
68
+ },
69
+ required: ['name'],
70
+ },
71
+ },
72
+ ];
73
+ async function readSchema(name) {
74
+ const major = SCHEMA_VERSIONS[name].split('.')[0];
75
+ const here = dirname(fileURLToPath(import.meta.url));
76
+ // dist/commands/mcp.js → ../../schemas/<name>.v<major>.json
77
+ const p = join(here, '..', '..', 'schemas', `${name}.v${major}.json`);
78
+ return JSON.parse(readFileSync(p, 'utf8'));
79
+ }
80
+ /** Capture process.stdout written by CLI commands and return it. MCP tool
81
+ * callbacks must return their result, not write it. The simplest way to
82
+ * reuse the existing --json printers without refactoring them is to
83
+ * redirect stdout for the duration of the call. */
84
+ async function captureStdout(fn) {
85
+ const origWrite = process.stdout.write.bind(process.stdout);
86
+ let captured = '';
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ process.stdout.write = (chunk, encoding, cb) => {
89
+ captured += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
90
+ if (typeof encoding === 'function')
91
+ encoding();
92
+ else if (cb)
93
+ cb();
94
+ return true;
95
+ };
96
+ try {
97
+ await fn();
98
+ }
99
+ finally {
100
+ process.stdout.write = origWrite;
101
+ }
102
+ return captured;
103
+ }
104
+ function jsonFromCapture(captured) {
105
+ try {
106
+ return JSON.parse(captured);
107
+ }
108
+ catch {
109
+ return { error: 'tool produced non-JSON output', raw: captured.slice(0, 1000) };
110
+ }
111
+ }
112
+ export async function runMcpServer() {
113
+ const server = new Server({
114
+ name: 'haystack-cli',
115
+ version: '1.0.0',
116
+ }, {
117
+ capabilities: { tools: {} },
118
+ });
119
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
120
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
121
+ const { name, arguments: args } = request.params;
122
+ const ok = (data) => ({
123
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
124
+ });
125
+ const fail = (msg) => ({
126
+ isError: true,
127
+ content: [{ type: 'text', text: msg }],
128
+ });
129
+ try {
130
+ if (name === 'triage_pr') {
131
+ const ref = args?.ref;
132
+ if (!ref)
133
+ return fail('ref required');
134
+ const pr = parsePrRef(ref);
135
+ const token = (await loadToken()) ?? undefined;
136
+ const [synthesis, manifest] = await Promise.all([
137
+ fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token),
138
+ fetchAutoFixManifest(pr.owner, pr.repo, pr.prNumber, token),
139
+ ]);
140
+ if (!synthesis)
141
+ return fail(`No triage data for ${pr.owner}/${pr.repo}#${pr.prNumber}`);
142
+ // Mirror triage --json envelope.
143
+ return ok(withSchema('triage', {
144
+ owner: pr.owner,
145
+ repo: pr.repo,
146
+ prNumber: pr.prNumber,
147
+ rating: synthesis.haystackRating,
148
+ autoFixerHandling: manifest?.fixedItems ?? [],
149
+ autoFixerSkipped: manifest?.leftForReview ?? [],
150
+ findings: (synthesis.synthesisDisplay ?? []).map((f) => ({
151
+ category: f.category,
152
+ summary: f.summary,
153
+ detail: f.detail,
154
+ agentFixPrompt: f.agentFixPrompt ?? null,
155
+ source: f.source ?? null,
156
+ })),
157
+ }));
158
+ }
159
+ if (name === 'pr_state') {
160
+ const ref = args?.ref;
161
+ if (!ref)
162
+ return fail('ref required');
163
+ const captured = await captureStdout(async () => {
164
+ await prReadCommand(ref, { json: true });
165
+ });
166
+ return ok(jsonFromCapture(captured));
167
+ }
168
+ if (name === 'schema') {
169
+ const n = args?.name;
170
+ if (!n || !(n in SCHEMA_VERSIONS)) {
171
+ return fail(`unknown schema; choose one of ${Object.keys(SCHEMA_VERSIONS).join(', ')}`);
172
+ }
173
+ return ok(await readSchema(n));
174
+ }
175
+ return fail(`unknown tool: ${name}`);
176
+ }
177
+ catch (err) {
178
+ return fail(`tool error: ${err instanceof Error ? err.message : String(err)}`);
179
+ }
180
+ });
181
+ const transport = new StdioServerTransport();
182
+ await server.connect(transport);
183
+ // The server runs until stdin closes. Stdio MCP gives no exit signal beyond
184
+ // that; we don't return.
185
+ }
@@ -0,0 +1,10 @@
1
+ export interface ParsedPR {
2
+ owner: string;
3
+ repo: string;
4
+ prNumber: number;
5
+ }
6
+ export declare function parsePrRef(ref: string): ParsedPR;
7
+ export interface PrReadOptions {
8
+ json?: boolean;
9
+ }
10
+ export declare function prReadCommand(ref: string, opts: PrReadOptions): Promise<void>;
@@ -0,0 +1,103 @@
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
+ import chalk from 'chalk';
12
+ import { loadToken } from '../utils/auth.js';
13
+ import { fetchRatingSynthesis } from '../utils/analysis-api.js';
14
+ import { parseRemoteUrl } from '../utils/git.js';
15
+ import { withSchema } from '../schema.js';
16
+ const GITHUB_PROXY = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
17
+ export function parsePrRef(ref) {
18
+ const urlMatch = ref.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
19
+ if (urlMatch)
20
+ return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
21
+ const qualifiedMatch = ref.match(/^([^/]+)\/([^#]+)#(\d+)$/);
22
+ if (qualifiedMatch)
23
+ return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
24
+ const bare = ref.match(/^#?(\d+)$/);
25
+ if (bare) {
26
+ const r = parseRemoteUrl('origin');
27
+ return { owner: r.owner, repo: r.repo, prNumber: parseInt(bare[1], 10) };
28
+ }
29
+ throw new Error(`Invalid PR reference: "${ref}". Use 123, owner/repo#123, or a GitHub URL.`);
30
+ }
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)}`);
42
+ }
43
+ return res.json();
44
+ }
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);
51
+ }
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';
72
+ }
73
+ const payload = {
74
+ owner: pr.owner,
75
+ repo: pr.repo,
76
+ prNumber: pr.prNumber,
77
+ bucket,
78
+ state: meta.merged ? 'merged' : meta.state,
79
+ 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
84
+ ? {
85
+ rating: synthesis.haystackRating,
86
+ completedAt: new Date().toISOString(),
87
+ headSha: meta.head.sha,
88
+ }
89
+ : null,
90
+ };
91
+ if (opts.json) {
92
+ console.log(JSON.stringify(withSchema('pr', payload), null, 2));
93
+ return;
94
+ }
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}`);
101
+ }
102
+ console.log(` ${chalk.dim(payload.haystackUrl)}\n`);
103
+ }
@@ -0,0 +1,4 @@
1
+ export declare function editRulesCommand(): Promise<void>;
2
+ export declare function validateRulesCommand(opts?: {
3
+ silent?: boolean;
4
+ }): Promise<void>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `haystack rules edit|validate` — manage .haystack/pr-rules.yml.
3
+ *
4
+ * Why local, not server-round-trip:
5
+ * pr-rules.yml is the source of truth, checked into the repo. The CLI's job
6
+ * is to make it ergonomic to edit (open in $EDITOR) and to fail loudly if
7
+ * the file would break the server-side parser (`packages/custom-rules`).
8
+ * The same parser is used here so "validate passes locally" == "won't
9
+ * break the next PR analysis."
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { resolve, dirname } from 'node:path';
14
+ import chalk from 'chalk';
15
+ const RULES_PATH = '.haystack/pr-rules.yml';
16
+ const STARTER_YAML = `version: 1
17
+
18
+ rules:
19
+ # Replace with your own rules. Each rule is either type: llm (judged by an
20
+ # LLM) or type: deterministic (regex / structural). See
21
+ # packages/custom-rules/README.md for the full schema.
22
+ - id: EXAMPLE001
23
+ name: Example rule
24
+ type: llm
25
+ severity: info
26
+ message: Replace this rule with one that matters to your team
27
+ llm:
28
+ prompt: >
29
+ This is a placeholder. Edit .haystack/pr-rules.yml and remove this rule.
30
+ `;
31
+ function rulesAbsPath() {
32
+ return resolve(process.cwd(), RULES_PATH);
33
+ }
34
+ export async function editRulesCommand() {
35
+ const path = rulesAbsPath();
36
+ if (!existsSync(path)) {
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ writeFileSync(path, STARTER_YAML, 'utf8');
39
+ console.log(chalk.dim(`Created ${RULES_PATH} with a starter rule.`));
40
+ }
41
+ const editor = process.env.VISUAL ?? process.env.EDITOR;
42
+ if (!editor) {
43
+ console.error(chalk.red('No $EDITOR or $VISUAL set.') +
44
+ `\nOpen ${chalk.bold(RULES_PATH)} manually, then run ` +
45
+ chalk.bold('haystack rules validate') + ' to check it.');
46
+ process.exit(2);
47
+ }
48
+ const before = statSync(path).mtimeMs;
49
+ const result = spawnSync(editor, [path], { stdio: 'inherit' });
50
+ if (result.status !== 0) {
51
+ console.error(chalk.red(`Editor exited with status ${result.status}.`));
52
+ process.exit(result.status ?? 1);
53
+ }
54
+ const after = statSync(path).mtimeMs;
55
+ if (after === before) {
56
+ console.log(chalk.dim('No changes.'));
57
+ return;
58
+ }
59
+ console.log(chalk.dim('Validating…'));
60
+ await validateRulesCommand({ silent: false });
61
+ }
62
+ export async function validateRulesCommand(opts = {}) {
63
+ const path = rulesAbsPath();
64
+ if (!existsSync(path)) {
65
+ console.error(chalk.red(`${RULES_PATH} not found.`));
66
+ console.error(`Run ${chalk.bold('haystack rules edit')} to create one.`);
67
+ process.exit(1);
68
+ }
69
+ const raw = readFileSync(path, 'utf8');
70
+ // Use the canonical parser. Importing dynamically keeps the CLI's hot path
71
+ // free of haystack-custom-rules unless this subcommand is actually run.
72
+ let parser = null;
73
+ try {
74
+ parser = (await import('haystack-custom-rules'));
75
+ }
76
+ catch (err) {
77
+ console.error(chalk.red('Could not load the rules parser (haystack-custom-rules).'), err instanceof Error ? err.message : err);
78
+ console.error(chalk.dim('The CLI uses the same parser as the server. If this failed, the workspace install is incomplete — run `pnpm install` from the repo root.'));
79
+ process.exit(1);
80
+ }
81
+ try {
82
+ const cfg = parser.parseRulesYaml(raw);
83
+ const ruleCount = cfg.rules?.length ?? 0;
84
+ if (!opts.silent) {
85
+ console.log(chalk.green(`✓ ${RULES_PATH} is valid (${ruleCount} rule${ruleCount === 1 ? '' : 's'}).`));
86
+ }
87
+ }
88
+ catch (err) {
89
+ console.error(chalk.red(`✗ ${RULES_PATH} is invalid:`));
90
+ if (err && typeof err === 'object' && 'message' in err) {
91
+ console.error(` ${err.message}`);
92
+ }
93
+ else {
94
+ console.error(' ' + String(err));
95
+ }
96
+ process.exit(1);
97
+ }
98
+ }
@@ -0,0 +1,2 @@
1
+ export declare function schemaCommand(name: string): void;
2
+ export declare function listSchemas(): void;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `haystack schema <command>` — print the committed JSON Schema for the
3
+ * machine-readable `--json` output of a given command.
4
+ *
5
+ * Schemas live in packages/haystack-cli/schemas/<name>.v<major>.json and ship
6
+ * with the npm package. A CI guard fails if the schema_version in code drifts
7
+ * from the committed schema without a version bump.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { dirname, join } from 'node:path';
12
+ import chalk from 'chalk';
13
+ import { SCHEMA_VERSIONS } from '../schema.js';
14
+ // Derive valid schema names from the registry so adding a new schema only
15
+ // requires editing SCHEMA_VERSIONS (PR008 — single source of truth).
16
+ const VALID = Object.keys(SCHEMA_VERSIONS);
17
+ export function schemaCommand(name) {
18
+ if (!VALID.includes(name)) {
19
+ console.error(chalk.red(`Unknown schema: "${name}".`) +
20
+ `\n\nAvailable: ${VALID.join(', ')}`);
21
+ process.exit(2);
22
+ }
23
+ const major = SCHEMA_VERSIONS[name].split('.')[0];
24
+ // dist/commands/schema-cmd.js → ../../schemas/<name>.v<major>.json
25
+ const schemaPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'schemas', `${name}.v${major}.json`);
26
+ try {
27
+ process.stdout.write(readFileSync(schemaPath, 'utf8'));
28
+ }
29
+ catch (err) {
30
+ console.error(chalk.red(`Could not read schema at ${schemaPath}:`), err instanceof Error ? err.message : err);
31
+ process.exit(1);
32
+ }
33
+ }
34
+ export function listSchemas() {
35
+ console.log(chalk.bold('Available --json schemas:\n'));
36
+ for (const name of VALID) {
37
+ console.log(` ${chalk.cyan(name.padEnd(10))} v${SCHEMA_VERSIONS[name]}`);
38
+ }
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')}`);
41
+ }
@@ -344,6 +344,53 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge
344
344
  `Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
345
345
  `Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
346
346
  }
347
+ /**
348
+ * Assign the onboarding actor (the human running `haystack setup`) to the
349
+ * bootstrap PR.
350
+ *
351
+ * Load-bearing for backfill parity with the web wizard
352
+ * (src/features/onboarding/services/onboardingApi.ts → assignAuthorToPr): when
353
+ * the bootstrap PR merges, the webhook handler's
354
+ * `_extract_onboarding_backfill_actor` figures out whose existing open PRs to
355
+ * analyze by reading the merged PR's assignees. The PR is authored by
356
+ * haystack[bot] (excluded as a bot) and carries no actor marker, so WITHOUT an
357
+ * assignee the actor can't be determined and the backfill is silently skipped
358
+ * (`[ONBOARDING_BACKFILL] Could not determine onboarding actor`) — leaving a
359
+ * freshly-onboarded user's pre-existing PRs un-analyzed. The web wizard assigns
360
+ * the user; the CLI must do the same.
361
+ *
362
+ * Best-effort: the PR is already open + labeled (which is what gates onboarding
363
+ * completion), so a failed assignment must not fail setup. Tracked so we notice
364
+ * if it starts failing systemically (e.g. after a GitHub App permission
365
+ * narrowing).
366
+ */
367
+ async function assignActorToPr(owner, repo, prNumber, login, token) {
368
+ if (!login) {
369
+ trackError('haystack_setup_assign_actor_no_login', { repo: `${owner}/${repo}`, pr_number: prNumber });
370
+ return;
371
+ }
372
+ try {
373
+ const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/assignees`, {
374
+ method: 'POST',
375
+ headers: ghHeaders(token),
376
+ body: JSON.stringify({ assignees: [login] }),
377
+ });
378
+ if (!res.ok) {
379
+ trackError('haystack_setup_assign_actor_failed', {
380
+ repo: `${owner}/${repo}`,
381
+ pr_number: prNumber,
382
+ status: res.status,
383
+ });
384
+ }
385
+ }
386
+ catch (err) {
387
+ trackError('haystack_setup_assign_actor_failed', {
388
+ repo: `${owner}/${repo}`,
389
+ pr_number: prNumber,
390
+ error_message: err instanceof Error ? err.message : String(err),
391
+ });
392
+ }
393
+ }
347
394
  /**
348
395
  * Protected-branch fallback: build ONE commit containing ALL the onboarding
349
396
  * files (`configFiles` + `.entire/settings.json`) via the Git Data API, CREATE
@@ -358,7 +405,7 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge
358
405
  * trace capture intent-drift triage runs on) must land in the single commit the
359
406
  * branch is created at. Mirrors the web wizard's PR-fallback path.
360
407
  */
361
- async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBranch, autoMergeEnabled) {
408
+ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBranch, autoMergeEnabled, assigneeLogin) {
362
409
  const baseSha = await getBranchSha(owner, repo, defaultBranch, token);
363
410
  // Branch prefix MUST stay `haystack/onboarding-` — the worker keys off it.
364
411
  // The timestamp avoids collisions when setup is re-run.
@@ -400,6 +447,11 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
400
447
  const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
401
448
  await createBranch(owner, repo, branchName, commitSha, token);
402
449
  const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token, autoMergeEnabled);
450
+ // Assign the actor BEFORE labeling. The onboarding-bootstrap backfill keys off
451
+ // the merged PR's assignees, and the auto-merge label can let the merge-queue
452
+ // cron merge this PR promptly — so the assignee must be in place first, else
453
+ // the merge webhook fires without it and the backfill can't attribute the run.
454
+ await assignActorToPr(owner, repo, pr.number, assigneeLogin, token);
403
455
  // Always apply the onboarding-bootstrap marker so the PR stays recognized in
404
456
  // the user's tabs (isOnboardingBootstrapPR); enroll for auto-merge only when
405
457
  // the repo hasn't opted out. With auto-merge off the PR is still opened, marked,
@@ -415,9 +467,9 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
415
467
  * Haystack" PR. The PR is enrolled for auto-merge only when the user kept
416
468
  * auto-merge on; otherwise it waits for them to review and merge it.
417
469
  */
418
- async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token) {
470
+ async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token, assigneeLogin) {
419
471
  const defaultBranch = await getDefaultBranch(owner, repo, token);
420
- const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
472
+ const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled, assigneeLogin);
421
473
  return { bootstrapPR };
422
474
  }
423
475
  const SCAN_POLL_INTERVAL_MS = 1500;
@@ -980,7 +1032,7 @@ function buildOnboardingFiles(result, autoMerge) {
980
1032
  files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge) });
981
1033
  return files;
982
1034
  }
983
- async function stepConfirm(byRepo, token) {
1035
+ async function stepConfirm(byRepo, token, assigneeLogin) {
984
1036
  const selectedRepos = [...byRepo.keys()];
985
1037
  console.log(chalk.bold('\n Step 4: Confirm & write configuration\n'));
986
1038
  // Per-repo summary of what will be written.
@@ -1025,7 +1077,7 @@ async function stepConfirm(byRepo, token) {
1025
1077
  const files = buildOnboardingFiles(result, autoMerge);
1026
1078
  try {
1027
1079
  ui.progress(`Opening Configure Haystack PR for ${repoFullName}...`);
1028
- const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
1080
+ const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token, assigneeLogin);
1029
1081
  ui.clearProgress();
1030
1082
  // Onboarding always opens a PR (never a direct commit). With auto-merge on,
1031
1083
  // Haystack merges it once its labels are processed; with auto-merge off it
@@ -1108,8 +1160,11 @@ async function runSetupFlow(options) {
1108
1160
  const byRepo = await stepScanRepos(selectedRepos, token);
1109
1161
  // Step 3: Review (toggle items off)
1110
1162
  await stepReview(byRepo);
1111
- // Step 4: Confirm & write the config files
1112
- const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token);
1163
+ // Step 4: Confirm & write the config files. Pass the authenticated user's
1164
+ // login so each bootstrap PR is assigned to them — the onboarding-bootstrap
1165
+ // backfill keys off the merged PR's assignees to decide whose existing open
1166
+ // PRs to analyze (parity with the web wizard).
1167
+ const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token, user.login);
1113
1168
  if (!confirmed) {
1114
1169
  ui.result({ status: 'cancelled' });
1115
1170
  return;
@@ -548,15 +548,26 @@ export async function submitCommand(options) {
548
548
  console.log(chalk.bold(' Haystack Analysis Results'));
549
549
  console.log(chalk.bold('━'.repeat(50) + '\n'));
550
550
  if (verdict.state === 'good-to-merge') {
551
+ // autoMerged here is the GH "merged: true" bit (not the
552
+ // analysis's canAutoMerge eligibility). Only claim the PR was
553
+ // merged when GitHub confirms it.
551
554
  if (verdict.autoMerged) {
552
- console.log(chalk.green.bold(' ✓ Auto-merged\n'));
553
- console.log(chalk.dim(' No issues found. PR was automatically merged.'));
555
+ console.log(chalk.green.bold(' ✓ Merged\n'));
556
+ console.log(chalk.dim(' No blocking issues. PR is merged on GitHub.'));
554
557
  }
555
558
  else {
556
559
  console.log(chalk.green.bold(' ✓ Good to merge\n'));
557
560
  console.log(chalk.dim(` ${verdict.summary}`));
558
561
  }
559
562
  }
563
+ else if (verdict.needsHumanReview) {
564
+ // Distinct from generic needs-input: analysis is clean but
565
+ // policy/sensitivity demands a human. Don't lump this in with
566
+ // "issues found" — the user's action is "request a reviewer",
567
+ // not "fix bugs".
568
+ console.log(chalk.cyan.bold(' 👤 Human review required\n'));
569
+ console.log(chalk.dim(` ${verdict.summary}`));
570
+ }
560
571
  else if (options.autoFix) {
561
572
  // Auto-fix flow: agent fixes straightforward issues in the background.
562
573
  // Issues appear in Feed immediately — user can review while agent works.
@@ -0,0 +1,14 @@
1
+ export interface HeadlessLoginOptions {
2
+ label?: string;
3
+ scope?: string[];
4
+ /** Skip the browser handshake and accept a pre-minted token directly.
5
+ * Useful for CI bootstrap (a one-time admin mints a token, stores it in
6
+ * the CI secret store, and the CI job pastes it via this flag). */
7
+ token?: string;
8
+ }
9
+ export declare function headlessLoginCommand(opts: HeadlessLoginOptions): Promise<void>;
10
+ export declare function headlessLogoutCommand(): Promise<void>;
11
+ export declare function listTokensCommand(opts: {
12
+ json?: boolean;
13
+ }): Promise<void>;
14
+ export declare function revokeTokenCommand(id: string): Promise<void>;