@haystackeditor/cli 0.15.5 → 0.15.7

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.
@@ -56,6 +56,10 @@ function buildAnswerMap(options) {
56
56
  answers.upgrade_entire = true;
57
57
  if (!('install_hooks' in answers))
58
58
  answers.install_hooks = true;
59
+ // Don't auto-launch a browser in an unattended --yes run; the feed URL is
60
+ // still printed and returned in the result for the caller to open.
61
+ if (!('open_feed' in answers))
62
+ answers.open_feed = false;
59
63
  }
60
64
  return answers;
61
65
  }
@@ -125,7 +129,7 @@ const CONFIG_COMMIT_MSG = 'chore: configure Haystack via CLI setup wizard';
125
129
  const AUTO_MERGE_LABEL = 'haystack:auto-merge';
126
130
  const ONBOARDING_BOOTSTRAP_LABEL = 'haystack:onboarding-bootstrap';
127
131
  const BOOTSTRAP_PR_TITLE = 'Configure Haystack';
128
- const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard because the default branch is protected.\n\n' +
132
+ const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard to configure Haystack for this repo.\n\n' +
129
133
  'It adds the Haystack onboarding config:\n' +
130
134
  '- `.haystack/pr-rules.yml` — rules Haystack enforces on every PR\n' +
131
135
  '- `.haystack/review-policy.md` — path-scoped review policies + review instructions\n' +
@@ -146,47 +150,6 @@ const BOOTSTRAP_PR_OUTRO_MANUAL = 'Auto-merge is off for this repo, so this PR i
146
150
  function encodeBranchPath(branch) {
147
151
  return branch.split('/').map(encodeURIComponent).join('/');
148
152
  }
149
- /**
150
- * Is the given branch protected such that a direct commit can't land —
151
- * either by legacy branch protection OR a repository ruleset?
152
- *
153
- * Decided from GitHub's *structured* signals, not error-message text:
154
- * - `GET /repos/{o}/{r}/branches/{b}` → `.protected` (legacy protection)
155
- * - `GET /repos/{o}/{r}/rules/branches/{b}` → a `pull_request` rule means
156
- * "changes must go through a PR" (rulesets)
157
- *
158
- * Called on the write-failure path: a 409/422 from the direct commit tells
159
- * us *that* something rejected the write; this tells us *whether* it was
160
- * protection (→ open a bootstrap PR) vs. a genuine bad request (→ surface
161
- * the error). If the probe itself can't be completed we throw — guessing
162
- * from message wording is exactly the brittleness we're avoiding here.
163
- */
164
- async function isBranchProtected(owner, repo, branch, token) {
165
- const headers = ghHeaders(token);
166
- const enc = encodeBranchPath(branch);
167
- // Legacy branch protection — the `protected` boolean is authoritative.
168
- const branchRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/branches/${enc}`, { headers });
169
- if (branchRes.ok) {
170
- const data = (await branchRes.json());
171
- if (data.protected === true)
172
- return true;
173
- }
174
- else if (branchRes.status !== 404) {
175
- throw new Error(`Couldn't check branch protection: ${branchRes.status} — ${await branchRes.text()}`);
176
- }
177
- // Repository rulesets — `rules/branches` collapses every active rule on
178
- // the branch into one list. A `pull_request` rule = "must merge via PR".
179
- const rulesRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/rules/branches/${enc}`, { headers });
180
- if (rulesRes.ok) {
181
- const rules = (await rulesRes.json());
182
- if (rules.some((r) => r.type === 'pull_request'))
183
- return true;
184
- }
185
- else if (rulesRes.status !== 404) {
186
- throw new Error(`Couldn't check repository rulesets: ${rulesRes.status} — ${await rulesRes.text()}`);
187
- }
188
- return false;
189
- }
190
153
  function ghHeaders(token) {
191
154
  return {
192
155
  Authorization: `Bearer ${token}`,
@@ -381,56 +344,6 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge
381
344
  `Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
382
345
  `Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
383
346
  }
384
- /** Wraps a contents-API write failure with its HTTP status so the caller can
385
- * distinguish a protection rejection (409/422 → PR fallback) from a genuine
386
- * error (→ surface it). */
387
- class GitHubWriteError extends Error {
388
- status;
389
- body;
390
- constructor(status, body) {
391
- super(`GitHub write failed: ${status}`);
392
- this.status = status;
393
- this.body = body;
394
- this.name = 'GitHubWriteError';
395
- }
396
- }
397
- /** Read the blob SHA of an existing file (needed to update it). Non-OK (incl.
398
- * 404 = file doesn't exist yet) returns null; the subsequent PUT then creates
399
- * the file, and surfaces a real error if the write itself fails. */
400
- async function fetchFileSha(owner, repo, path, token) {
401
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, { headers: ghHeaders(token) });
402
- if (!res.ok) {
403
- if (res.status !== 404) {
404
- trackError('haystack_setup_fetch_file_sha_failed', {
405
- owner,
406
- repo,
407
- path,
408
- status: res.status,
409
- });
410
- }
411
- return null;
412
- }
413
- const data = (await res.json());
414
- return data.sha ?? null;
415
- }
416
- /** PUT a single file to the default branch via the contents API. Throws
417
- * GitHubWriteError on any non-OK response so the caller can branch on status. */
418
- async function putFile(owner, repo, path, content, message, sha, token) {
419
- const body = {
420
- message,
421
- content: Buffer.from(content).toString('base64'),
422
- };
423
- if (sha)
424
- body.sha = sha;
425
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, {
426
- method: 'PUT',
427
- headers: ghHeaders(token),
428
- body: JSON.stringify(body),
429
- });
430
- if (!res.ok) {
431
- throw new GitHubWriteError(res.status, await res.text().catch(() => ''));
432
- }
433
- }
434
347
  /**
435
348
  * Protected-branch fallback: build ONE commit containing ALL the onboarding
436
349
  * files (`configFiles` + `.entire/settings.json`) via the Git Data API, CREATE
@@ -495,50 +408,17 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
495
408
  return { prUrl: pr.html_url, entireSettingsBundled };
496
409
  }
497
410
  /**
498
- * Write the onboarding config files to a repo. Tries a direct commit per file
499
- * to the default branch first; on a protected-branch / ruleset rejection
500
- * (409/422 confirmed by a structured protection probe) falls back to ONE
501
- * bootstrap PR containing all files. Mirrors the web wizard's
502
- * writeOnboardingFiles.
503
- *
504
- * Returns `{ bootstrapPR }` when the PR fallback was used (so the caller can
505
- * thread the branch into step 7), `{}` on clean direct commits.
411
+ * Write the onboarding config files to a repo by ALWAYS opening a PR
412
+ * (branch one commit PR) — never a direct commit to the default branch.
413
+ * Matches the web wizard (#1936): a silent push to someone's default branch is
414
+ * jarring and hard to undo, so onboarding always goes through a "Configure
415
+ * Haystack" PR. The PR is enrolled for auto-merge only when the user kept
416
+ * auto-merge on; otherwise it waits for them to review and merge it.
506
417
  */
507
418
  async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token) {
508
- // Look up existing SHAs once on the default branch so updates to files that
509
- // already exist carry the right sha. Done before any write so a mid-loop
510
- // failure doesn't leave us guessing.
511
- const shaByPath = new Map();
512
- for (const f of files) {
513
- shaByPath.set(f.path, await fetchFileSha(owner, repo, f.path, token));
514
- }
515
- let successfulWrites = 0;
516
- try {
517
- for (const f of files) {
518
- await putFile(owner, repo, f.path, f.content, f.message, shaByPath.get(f.path) ?? null, token);
519
- successfulWrites++;
520
- }
521
- return {};
522
- }
523
- catch (err) {
524
- // Only a 409/422 can be a protection rejection. Anything else (auth, 404,
525
- // 5xx, network) is a genuine error — surface it.
526
- const status = err instanceof GitHubWriteError ? err.status : 0;
527
- if (status !== 409 && status !== 422)
528
- throw err;
529
- if (successfulWrites > 0) {
530
- throw err;
531
- }
532
- // Probe GitHub's *structured* protection signals to decide: protection
533
- // (→ PR fallback) vs. a genuine bad request / sha conflict (→ surface the
534
- // error). This replaces brittle error-message substring matching.
535
- const defaultBranch = await getDefaultBranch(owner, repo, token);
536
- if (!(await isBranchProtected(owner, repo, defaultBranch, token))) {
537
- throw err;
538
- }
539
- const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
540
- return { bootstrapPR };
541
- }
419
+ const defaultBranch = await getDefaultBranch(owner, repo, token);
420
+ const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
421
+ return { bootstrapPR };
542
422
  }
543
423
  const SCAN_POLL_INTERVAL_MS = 1500;
544
424
  // A run still marked `running` but whose KV state hasn't been touched for this
@@ -864,8 +744,6 @@ async function stepSelectRepos(token) {
864
744
  const PR_RULES_PATH = '.haystack/pr-rules.yml';
865
745
  const REVIEW_POLICY_PATH = '.haystack/review-policy.md';
866
746
  const HAYSTACK_CONFIG_PATH = '.haystack.json';
867
- const RULES_COMMIT_MSG = 'chore: configure Haystack rules via CLI setup wizard';
868
- const POLICY_COMMIT_MSG = 'chore: add review policies via CLI setup wizard';
869
747
  /**
870
748
  * Scan each selected repo with one unified scan and collect the proposed
871
749
  * rules / policies / instructions per repo. A failed scan for one repo records
@@ -1093,13 +971,13 @@ function buildOnboardingFiles(result, autoMerge) {
1093
971
  const files = [];
1094
972
  const prRules = buildPrRules(result.rules);
1095
973
  if (prRules.length > 0) {
1096
- files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules), message: RULES_COMMIT_MSG });
974
+ files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules) });
1097
975
  }
1098
976
  const reviewPolicyMd = buildReviewPolicyMarkdown(result.policies, result.instructions);
1099
977
  if (reviewPolicyMd) {
1100
- files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd, message: POLICY_COMMIT_MSG });
978
+ files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd });
1101
979
  }
1102
- files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge), message: CONFIG_COMMIT_MSG });
980
+ files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge) });
1103
981
  return files;
1104
982
  }
1105
983
  async function stepConfirm(byRepo, token) {
@@ -1146,24 +1024,17 @@ async function stepConfirm(byRepo, token) {
1146
1024
  const result = byRepo.get(repoFullName);
1147
1025
  const files = buildOnboardingFiles(result, autoMerge);
1148
1026
  try {
1149
- ui.progress(`Writing to ${repoFullName}...`);
1027
+ ui.progress(`Opening Configure Haystack PR for ${repoFullName}...`);
1150
1028
  const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
1151
1029
  ui.clearProgress();
1152
- if (writeResult.bootstrapPR) {
1153
- // Default branch was protected config landed in a bootstrap PR. With
1154
- // auto-merge on, Haystack merges it once its labels are processed; with
1155
- // auto-merge off it waits for the user. Step 7 ensures
1156
- // .entire/settings.json is on the same PR.
1157
- bootstrapPRs.set(repoFullName, writeResult.bootstrapPR);
1158
- const note = autoMerge
1159
- ? ' (default branch protected — opened a PR)'
1160
- : ' (default branch protected — opened a PR for you to review and merge)';
1161
- console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(note));
1162
- console.log(chalk.dim(` ${writeResult.bootstrapPR.prUrl}`));
1163
- }
1164
- else {
1165
- console.log(chalk.green(` ✓ ${repoFullName}`));
1166
- }
1030
+ // Onboarding always opens a PR (never a direct commit). With auto-merge on,
1031
+ // Haystack merges it once its labels are processed; with auto-merge off it
1032
+ // waits for the user. Step 7 ensures .entire/settings.json rode along in
1033
+ // the same commit.
1034
+ bootstrapPRs.set(repoFullName, writeResult.bootstrapPR);
1035
+ const note = autoMerge ? ' (opened a PR)' : ' (opened a PR for you to review and merge)';
1036
+ console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(note));
1037
+ console.log(chalk.dim(` ${writeResult.bootstrapPR.prUrl}`));
1167
1038
  }
1168
1039
  catch (err) {
1169
1040
  ui.clearProgress();
@@ -1242,22 +1113,34 @@ async function runSetupFlow(options) {
1242
1113
  ui.result({ status: 'cancelled' });
1243
1114
  return;
1244
1115
  }
1245
- // Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
1246
- // which repos had a protected default branch so it can commit
1247
- // .entire/settings.json to the same PR instead of a doomed direct PUT.
1248
- // --skip-entire skips the whole step (binary install, hooks, AND the remote
1249
- // .entire/settings.json write) — not just the install prompt.
1116
+ // Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it which
1117
+ // repos got a Configure Haystack PR (all of them) so it can report that
1118
+ // .entire/settings.json rode along in that PR's commit.
1119
+ // --skip-entire skips the whole step (binary install + hooks) not just the
1120
+ // install prompt.
1250
1121
  if (options.skipEntire) {
1251
1122
  console.log(chalk.dim(' Skipping session tracking (--skip-entire).\n'));
1252
1123
  }
1253
1124
  else {
1254
1125
  await stepInstallEntire(selectedRepos, token, bootstrapPRs);
1255
1126
  }
1127
+ // Close the loop: point the user to their Haystack feed (the inbox/card stack
1128
+ // where their PRs surface). Interactive users get offered a browser open; an
1129
+ // agent gets the URL in the `result` event to surface/open as it sees fit.
1130
+ const feedUrl = `${HAYSTACK_API}/inbox`;
1131
+ console.log(chalk.bold('\n Your Haystack feed:'));
1132
+ console.log(` ${chalk.cyan(feedUrl)}\n`);
1133
+ if (ui.interactive) {
1134
+ const openFeed = await ui.confirm({ id: 'open_feed', message: 'Open your Haystack feed now?', default: true });
1135
+ if (openFeed)
1136
+ tryOpenBrowser(feedUrl);
1137
+ }
1256
1138
  // Final structured outcome for non-interactive/agent callers.
1257
1139
  ui.result({
1258
1140
  status: 'complete',
1259
1141
  repos: selectedRepos,
1260
1142
  pullRequests: [...bootstrapPRs.entries()].map(([repo, pr]) => ({ repo, url: pr.prUrl })),
1143
+ feedUrl,
1261
1144
  });
1262
1145
  }
1263
1146
  // =============================================================================
@@ -1344,53 +1227,6 @@ async function installEntireBinary() {
1344
1227
  return false;
1345
1228
  }
1346
1229
  }
1347
- /**
1348
- * Write .entire/settings.json directly to the default branch.
1349
- *
1350
- * This is the DIRECT path only. Repos whose default branch is protected
1351
- * never reach here — step 6's openBootstrapConfigPR already bundled
1352
- * .entire/settings.json into the bootstrap PR's single commit (on a ~ALL
1353
- * ruleset you can't add it as a later commit, so it must ride along in the
1354
- * branch-creation commit). stepInstallEntire skips this call for those
1355
- * repos. So if we're here, step 6 proved the default branch accepts a
1356
- * direct commit.
1357
- */
1358
- async function configureEntireSettingsViaAPI(repoFullName, token) {
1359
- const [owner, repo] = repoFullName.split('/');
1360
- const result = await buildEntireSettingsContent(repoFullName, token);
1361
- // null → file already exists unchanged; nothing to write.
1362
- if (result === null)
1363
- return;
1364
- // buildEntireSettingsContent already fetched the file — reuse its
1365
- // content + existingSha. A second GET here would (a) be redundant and
1366
- // (b) on a transient non-404 failure fall back to existingSha=null,
1367
- // turning the PUT into a create that GitHub rejects 422 — masking the
1368
- // real read error. (Non-404 read failures already threw upstream.)
1369
- const { content, existingSha } = result;
1370
- const headers = ghHeaders(token);
1371
- const body = {
1372
- message: 'chore: configure Entire CLI settings',
1373
- content: Buffer.from(content).toString('base64'),
1374
- };
1375
- if (existingSha)
1376
- body.sha = existingSha;
1377
- const putResp = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/.entire/settings.json`, {
1378
- method: 'PUT',
1379
- headers,
1380
- body: JSON.stringify(body),
1381
- });
1382
- if (!putResp.ok) {
1383
- const errText = await putResp.text().catch(() => '');
1384
- const error = new Error(`GitHub API ${putResp.status}: ${errText}`);
1385
- trackError('entire_settings_write_failed', {
1386
- error_message: error.message,
1387
- repo: repoFullName,
1388
- status: putResp.status,
1389
- had_existing: !!existingSha,
1390
- });
1391
- throw error;
1392
- }
1393
- }
1394
1230
  async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1395
1231
  console.log(chalk.bold('\n Step 7: Session tracking (Entire CLI)\n'));
1396
1232
  const status = isEntireInstalled();
@@ -1429,48 +1265,28 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1429
1265
  if (!userConsented) {
1430
1266
  console.log(chalk.dim(' Skipping session tracking.\n'));
1431
1267
  }
1432
- // Configure .entire/settings.json on each repo via GitHub API
1433
- // (works even if binary install failed settings are remote; skip if user declined).
1434
- // Repos whose default branch was protected went the bootstrap-PR route in
1435
- // step 6, which ALREADY bundled .entire/settings.json into that PR's
1436
- // single commit (on a ~ALL ruleset it can't be added as a later commit).
1437
- // So we skip those here and only direct-write the non-protected repos.
1268
+ // Onboarding always opens a PR, and .entire/settings.json rides along in that
1269
+ // PR's single commit (see openBootstrapConfigPR), so there's nothing to write
1270
+ // separately here just report what landed.
1438
1271
  if (userConsented) {
1439
- let configured = 0;
1440
1272
  let bundled = 0;
1441
1273
  for (const repoFullName of selectedRepos) {
1442
1274
  const bootstrapPR = bootstrapPRs.get(repoFullName);
1443
- if (bootstrapPR) {
1444
- if (bootstrapPR.entireSettingsBundled) {
1445
- // .entire/settings.json made it into the bootstrap PR's commit.
1446
- console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (in bootstrap PR)'));
1447
- bundled++;
1448
- }
1449
- else {
1450
- // The bootstrap PR shipped the config files but not
1451
- // .entire/settings.json — its optional read failed transiently. Don't
1452
- // count it as configured; the warning was already shown when the PR
1453
- // was opened.
1454
- console.log(chalk.yellow(` ⚠ ${repoFullName}`) +
1455
- chalk.dim(' — bootstrap PR has config files only; re-run setup to add session tracking'));
1456
- }
1457
- continue;
1275
+ if (bootstrapPR?.entireSettingsBundled) {
1276
+ console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (in PR)'));
1277
+ bundled++;
1458
1278
  }
1459
- try {
1460
- ui.progress(`Configuring ${repoFullName}...`);
1461
- await configureEntireSettingsViaAPI(repoFullName, token);
1462
- ui.clearProgress();
1463
- console.log(chalk.green(` ${repoFullName}`));
1464
- configured++;
1465
- }
1466
- catch (err) {
1467
- ui.clearProgress();
1468
- console.log(chalk.yellow(` ⚠ ${repoFullName}: ${err instanceof Error ? err.message : err}`));
1279
+ else {
1280
+ // The PR shipped the config files but not .entire/settings.json — its
1281
+ // optional read failed transiently. The warning was already shown when
1282
+ // the PR was opened.
1283
+ console.log(chalk.yellow(` ${repoFullName}`) +
1284
+ chalk.dim(' — PR has config files only; re-run setup to add session tracking'));
1469
1285
  }
1470
1286
  }
1471
- if (configured > 0 || bundled > 0) {
1472
- console.log(chalk.green(`\n ✓ Session tracking configured on ${configured + bundled} repo(s) (transcripts auto-compacted)\n`));
1473
- trackSetupEvent('entire_configured', { configured: configured + bundled, total: selectedRepos.length });
1287
+ if (bundled > 0) {
1288
+ console.log(chalk.green(`\n ✓ Session tracking configured on ${bundled} repo(s) (transcripts auto-compacted)\n`));
1289
+ trackSetupEvent('entire_configured', { configured: bundled, total: selectedRepos.length });
1474
1290
  }
1475
1291
  else {
1476
1292
  console.log(chalk.yellow('\n ⚠ Could not configure session tracking on any repos.\n'));
@@ -68,20 +68,54 @@ function parsePRIdentifier(identifier) {
68
68
  // ============================================================================
69
69
  // Output formatting
70
70
  // ============================================================================
71
+ /**
72
+ * Decide whether a finding is one the auto-fixer is already handling (and thus
73
+ * shouldn't be re-fixed). The fixer re-derives — often paraphrases — its manifest
74
+ * summaries rather than copying them verbatim, so exact string matching misses
75
+ * them. This mirrors the normalized + fuzzy matching the backend uses in
76
+ * agent/cloudflare/src/auto-fix-post-synthesis.ts (normalizeText / categoriesCompatible).
77
+ */
78
+ function normalizeText(value) {
79
+ return value.toLowerCase().replace(/\s+/g, ' ').trim();
80
+ }
81
+ /** Categories match if equal, or if one's tokens are a subset of the other's. */
82
+ function categoriesCompatible(left, right) {
83
+ const a = normalizeText(left);
84
+ const b = normalizeText(right);
85
+ if (a === b)
86
+ return true;
87
+ const aTokens = a.split(/[^a-z0-9]+/).filter(Boolean);
88
+ const bTokens = b.split(/[^a-z0-9]+/).filter(Boolean);
89
+ const aSet = new Set(aTokens);
90
+ const bSet = new Set(bTokens);
91
+ return ((bTokens.length > 0 && bTokens.every((t) => aSet.has(t))) ||
92
+ (aTokens.length > 0 && aTokens.every((t) => bSet.has(t))));
93
+ }
94
+ /** True if the finding matches any item the auto-fixer is handling. */
95
+ function isAutoFixing(finding, manifest) {
96
+ const items = manifest?.fixedItems ?? [];
97
+ if (items.length === 0)
98
+ return false;
99
+ const summaryKey = normalizeText(finding.summary);
100
+ if (summaryKey.length === 0)
101
+ return false;
102
+ return items.some((item) => normalizeText(item.summary) === summaryKey &&
103
+ categoriesCompatible(finding.category, item.category));
104
+ }
71
105
  function printRichOutput(pr, synthesis, manifest) {
72
106
  const reviewUrl = `https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}`;
73
107
  console.log(`\n ${chalk.bold(`Haystack Triage: ${pr.owner}/${pr.repo}#${pr.prNumber}`)}\n`);
74
108
  // Auto-fixer status — what's being fixed vs what it skipped
75
109
  if (manifest && (manifest.fixedItems.length > 0 || manifest.leftForReview.length > 0)) {
76
110
  if (manifest.fixedItems.length > 0) {
77
- console.log(chalk.bold(' Auto-fixer is handling:\n'));
111
+ console.log(chalk.bold(' Auto-fixer is handling these (leave them alone):\n'));
78
112
  for (const item of manifest.fixedItems) {
79
113
  console.log(` - [${item.category}] ${item.summary}`);
80
114
  }
81
- console.log('');
115
+ console.log(` ${chalk.dim("Don't fix these. The auto-fixer is already on them and will push its own commit.")}\n`);
82
116
  }
83
117
  if (manifest.leftForReview.length > 0) {
84
- console.log(chalk.bold(' Auto-fixer skipped:\n'));
118
+ console.log(chalk.bold(' Auto-fixer skipped these (they need you):\n'));
85
119
  for (const item of manifest.leftForReview) {
86
120
  console.log(` - [${item.category}] ${item.summary}`);
87
121
  console.log(` ${chalk.dim(item.reason)}`);
@@ -89,10 +123,13 @@ function printRichOutput(pr, synthesis, manifest) {
89
123
  console.log('');
90
124
  }
91
125
  }
92
- // Findings — the actionable output for agents
93
- const findings = synthesis.synthesisDisplay || [];
126
+ // Findings — the actionable output for agents. Exclude anything the auto-fixer
127
+ // is already handling so the agent doesn't duplicate (and fight) its work.
128
+ const allFindings = synthesis.synthesisDisplay || [];
129
+ const findings = allFindings.filter((f) => !isAutoFixing(f, manifest));
130
+ const fixingCount = manifest?.fixedItems?.length ?? 0;
94
131
  if (findings.length > 0) {
95
- console.log(chalk.bold(' Findings:\n'));
132
+ console.log(chalk.bold(' Findings to address:\n'));
96
133
  for (let i = 0; i < findings.length; i++) {
97
134
  const finding = findings[i];
98
135
  const sourceTag = finding.source ? ` (${finding.source})` : '';
@@ -112,7 +149,11 @@ function printRichOutput(pr, synthesis, manifest) {
112
149
  console.log('');
113
150
  }
114
151
  }
115
- else if (!manifest || manifest.fixedItems.length === 0) {
152
+ else if (fixingCount > 0 && allFindings.length > 0) {
153
+ // Every finding is being auto-fixed — nothing left for the agent.
154
+ console.log(chalk.dim(' Nothing to address. Every finding is being auto-fixed (see above).\n'));
155
+ }
156
+ else {
116
157
  console.log(chalk.dim(' No findings.\n'));
117
158
  }
118
159
  console.log(` ${chalk.dim(reviewUrl)}\n`);
@@ -123,6 +164,7 @@ function printJsonOutput(pr, synthesis, manifest) {
123
164
  repo: pr.repo,
124
165
  prNumber: pr.prNumber,
125
166
  rating: synthesis.haystackRating,
167
+ // Issues the auto-fixer is already on — leave these alone, do not re-fix.
126
168
  autoFixerHandling: manifest?.fixedItems ?? [],
127
169
  autoFixerSkipped: manifest?.leftForReview ?? [],
128
170
  findings: (synthesis.synthesisDisplay || []).map((f) => ({
@@ -131,6 +173,8 @@ function printJsonOutput(pr, synthesis, manifest) {
131
173
  detail: f.detail,
132
174
  agentFixPrompt: f.agentFixPrompt || null,
133
175
  source: f.source || null,
176
+ // True when the auto-fixer is handling this finding — don't fix it yourself.
177
+ autoFixing: isAutoFixing(f, manifest),
134
178
  })),
135
179
  };
136
180
  console.log(JSON.stringify(output, null, 2));
@@ -159,16 +203,19 @@ function printHookOutput(pr, synthesis, manifest, status) {
159
203
  console.log(`[Haystack] ${label}: Analysis in progress...`);
160
204
  return;
161
205
  }
162
- const findings = synthesis.synthesisDisplay || [];
163
206
  const stars = ratingStars(synthesis.haystackRating);
164
207
  const fixingCount = manifest?.fixedItems?.length ?? 0;
165
- if (findings.length === 0 && fixingCount === 0) {
208
+ // Only count findings the auto-fixer is NOT handling \u2014 those are what need the user.
209
+ const actionable = (synthesis.synthesisDisplay || []).filter((f) => !isAutoFixing(f, manifest));
210
+ if (actionable.length === 0 && fixingCount === 0) {
166
211
  console.log(`[Haystack] \u2705 ${label} ${stars}: Good to merge`);
167
212
  }
213
+ else if (actionable.length === 0) {
214
+ // Everything is being auto-fixed \u2014 nothing for the user to do.
215
+ console.log(`[Haystack] \u2705 ${label} ${stars}: ${fixingCount} being auto-fixed, nothing for you to do`);
216
+ }
168
217
  else {
169
- const parts = [];
170
- if (findings.length > 0)
171
- parts.push(`${findings.length} finding(s)`);
218
+ const parts = [`${actionable.length} finding(s)`];
172
219
  if (fixingCount > 0)
173
220
  parts.push(`${fixingCount} being auto-fixed`);
174
221
  console.log(`[Haystack] \u26A0\uFE0F ${label} ${stars}: Needs your input (${parts.join(', ')})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {