@haystackeditor/cli 0.14.5 → 0.15.0

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.
@@ -11,4 +11,19 @@
11
11
  * 7. Confirm & write .haystack.json to selected repos
12
12
  * 8. Install session tracking (Entire CLI + .entire/settings.json + local hooks)
13
13
  */
14
- export declare function setupCommand(): Promise<void>;
14
+ /**
15
+ * Flags accepted by `haystack setup`. With none, it runs the interactive TTY
16
+ * wizard. `--json` switches to the coding-agent protocol (NDJSON on stdout,
17
+ * answers on stdin). Any pre-supplied answer (a flag or --answers entry) skips
18
+ * the matching prompt, so the same flow runs fully autonomously when every
19
+ * decision is known up-front.
20
+ */
21
+ export interface SetupOptions {
22
+ json?: boolean;
23
+ repos?: string[];
24
+ yes?: boolean;
25
+ autoMerge?: boolean;
26
+ skipEntire?: boolean;
27
+ answersFile?: string;
28
+ }
29
+ export declare function setupCommand(options?: SetupOptions): Promise<void>;
@@ -12,7 +12,6 @@
12
12
  * 8. Install session tracking (Entire CLI + .entire/settings.json + local hooks)
13
13
  */
14
14
  import chalk from 'chalk';
15
- import inquirer from 'inquirer';
16
15
  import { execFileSync, execSync } from 'child_process';
17
16
  import { mkdirSync } from 'fs';
18
17
  import { basename, join } from 'path';
@@ -20,6 +19,41 @@ import { resolveAuthContext } from '../utils/auth.js';
20
19
  import { hooksInstall } from './hooks.js';
21
20
  import { findGitRoot } from '../utils/hooks.js';
22
21
  import { trackError, trackSetupEvent } from '../utils/telemetry.js';
22
+ import { createPrompter, loadAnswersFile } from '../utils/prompter.js';
23
+ /** The active prompter for this `setup` invocation (TTY or JSON). */
24
+ let ui;
25
+ /**
26
+ * Translate CLI flags into a pre-supplied answer map keyed by question id.
27
+ * Explicit flags win; `--answers` fills the rest. Anything still missing is
28
+ * asked at runtime (TTY prompt or JSON `question` event).
29
+ */
30
+ function buildAnswerMap(options) {
31
+ const answers = options.answersFile ? loadAnswersFile(options.answersFile) : {};
32
+ if (options.repos && options.repos.length > 0)
33
+ answers.select_repos = options.repos;
34
+ if (typeof options.autoMerge === 'boolean')
35
+ answers.auto_merge = options.autoMerge;
36
+ if (options.skipEntire) {
37
+ answers.install_entire = false;
38
+ answers.upgrade_entire = false;
39
+ answers.install_hooks = false;
40
+ }
41
+ if (options.yes) {
42
+ // Accept defaults non-interactively: don't open the toggle UI (keep every
43
+ // discovered item) and confirm the write. Only fill ids not already set.
44
+ if (!('want_to_toggle' in answers))
45
+ answers.want_to_toggle = false;
46
+ if (!('confirm_write' in answers))
47
+ answers.confirm_write = true;
48
+ if (!('auto_merge' in answers))
49
+ answers.auto_merge = true;
50
+ if (!('install_entire' in answers))
51
+ answers.install_entire = false;
52
+ if (!('install_hooks' in answers))
53
+ answers.install_hooks = false;
54
+ }
55
+ return answers;
56
+ }
23
57
  // =============================================================================
24
58
  // Constants
25
59
  // =============================================================================
@@ -310,12 +344,16 @@ async function buildEntireSettingsContent(repoFullName, token) {
310
344
  return null;
311
345
  return { content: updatedContent, existingSha };
312
346
  }
313
- async function addBootstrapLabels(owner, repo, prNumber, prUrl, token) {
314
- // CRITICAL, not best-effort: without BOTH labels the worker's bootstrap
315
- // merge path never discovers the PR and it sits open forever. The label
316
- // API failing is almost always transient, so retry a few times before
317
- // giving up.
318
- const labels = [AUTO_MERGE_LABEL, ONBOARDING_BOOTSTRAP_LABEL];
347
+ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge) {
348
+ // CRITICAL, not best-effort. ONBOARDING_BOOTSTRAP_LABEL is the identity marker
349
+ // that keeps the PR recognized in the user's Analyzing / Good to Merge tabs
350
+ // (isOnboardingBootstrapPR) and lets the worker's bootstrap path discover it
351
+ // so it's ALWAYS applied. AUTO_MERGE_LABEL enrolls the PR for auto-merge, so add
352
+ // it only when auto-merge is on. The label API failing is almost always
353
+ // transient, so retry a few times before giving up.
354
+ const labels = autoMerge
355
+ ? [ONBOARDING_BOOTSTRAP_LABEL, AUTO_MERGE_LABEL]
356
+ : [ONBOARDING_BOOTSTRAP_LABEL];
319
357
  let lastErr = '';
320
358
  for (let attempt = 1; attempt <= 3; attempt++) {
321
359
  const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/labels`, {
@@ -444,14 +482,11 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
444
482
  const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
445
483
  await createBranch(owner, repo, branchName, commitSha, token);
446
484
  const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token, autoMergeEnabled);
447
- // Only enroll the PR for auto-merge when the repo hasn't opted out. Applying
448
- // the labels on an auto-merge-disabled repo would force-merge a config PR the
449
- // user wants to review themselves (e.g. a repo set auto-merge-off via the web
450
- // wizard, then re-run through `haystack setup`). Without labels the worker
451
- // never picks it up; the PR is still opened and analyzed, it just waits.
452
- if (autoMergeEnabled) {
453
- await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token);
454
- }
485
+ // Always apply the onboarding-bootstrap marker so the PR stays recognized in
486
+ // the user's tabs (isOnboardingBootstrapPR); enroll for auto-merge only when
487
+ // the repo hasn't opted out. With auto-merge off the PR is still opened, marked,
488
+ // and analyzed it just waits for the user to merge it.
489
+ await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token, autoMergeEnabled);
455
490
  return { prUrl: pr.html_url, entireSettingsBundled };
456
491
  }
457
492
  /**
@@ -551,7 +586,7 @@ async function runUnifiedScan(repo, token) {
551
586
  }
552
587
  if (statusResp.status === 404) {
553
588
  // State expired (TTL) or runId never registered — surface, don't hang.
554
- process.stdout.write('\r\x1b[K');
589
+ ui.clearProgress();
555
590
  throw new Error('Scan state expired — please retry.');
556
591
  }
557
592
  if (!statusResp.ok) {
@@ -570,14 +605,11 @@ async function runUnifiedScan(repo, token) {
570
605
  const key = `${state.progress.message}|${state.progress.detail ?? ''}`;
571
606
  if (key !== lastProgressKey) {
572
607
  lastProgressKey = key;
573
- process.stdout.write(`\r${chalk.dim(' ' + state.progress.message)}`);
574
- if (state.progress.detail)
575
- process.stdout.write(chalk.dim(` — ${state.progress.detail}`));
576
- process.stdout.write('\x1b[K');
608
+ ui.progress(state.progress.message, state.progress.detail);
577
609
  }
578
610
  }
579
611
  if (state.status === 'done') {
580
- process.stdout.write('\r\x1b[K');
612
+ ui.clearProgress();
581
613
  if (!state.result)
582
614
  throw new Error('Scan completed but returned no result.');
583
615
  return {
@@ -587,11 +619,11 @@ async function runUnifiedScan(repo, token) {
587
619
  };
588
620
  }
589
621
  if (state.status === 'error') {
590
- process.stdout.write('\r\x1b[K');
622
+ ui.clearProgress();
591
623
  throw new Error(state.error || 'Scan failed');
592
624
  }
593
625
  if (state.status === 'running' && Date.now() - state.updatedAt > SCAN_STALE_MS) {
594
- process.stdout.write('\r\x1b[K');
626
+ ui.clearProgress();
595
627
  throw new Error('Scan stalled (no progress) — please retry.');
596
628
  }
597
629
  }
@@ -641,26 +673,33 @@ class InstallationsAuthError extends Error {
641
673
  }
642
674
  }
643
675
  async function fetchHaystackInstallations(token) {
644
- // /user/installations lists App installations the OAuth-authenticated user
645
- // can manage. Returns 200 with `installations: []` when none exist.
646
- const res = await fetch(`${GITHUB_API}/user/installations?per_page=100`, {
676
+ // Detect the Haystack App via the auth-worker, NOT GitHub's
677
+ // `GET /user/installations`. That GitHub endpoint requires a GitHub App
678
+ // user-to-server token; the CLI's `haystack login` token is a classic OAuth
679
+ // token, so GitHub answers 403 ("must authenticate with an access token
680
+ // authorized to a GitHub App") — which made step 0 falsely report the App as
681
+ // uninstalled and exit. The auth-worker resolves installations server-side
682
+ // with the App's own JWT, so it works with the CLI's token. Every entry it
683
+ // returns IS a Haystack installation (the worker only knows about this App),
684
+ // so there's no app_slug to filter on.
685
+ const res = await fetch(`${HAYSTACK_API}/api/github/user-installations`, {
647
686
  headers: {
648
687
  Authorization: `Bearer ${token}`,
649
- Accept: 'application/vnd.github.v3+json',
688
+ Accept: 'application/json',
650
689
  'User-Agent': 'Haystack-CLI',
651
690
  },
652
691
  });
653
692
  if (!res.ok) {
654
- // 401/403 are auth-bucket failures token revoked, expired, or missing
655
- // the right scopes. Retrying never recovers; bail with a distinct error
656
- // type so the poll loop can short-circuit and prompt re-auth.
693
+ // 401/403 here are genuine auth failures (the token the worker validates is
694
+ // bad), unlike the GitHub endpoint's spurious 403. Retrying won't recover;
695
+ // bail with a distinct error type so the poll loop can prompt re-auth.
657
696
  if (res.status === 401 || res.status === 403) {
658
697
  throw new InstallationsAuthError(res.status, `${res.status} ${await res.text()}`);
659
698
  }
660
699
  throw new Error(`failed to fetch installations: ${res.status} ${await res.text()}`);
661
700
  }
662
701
  const data = (await res.json());
663
- return (data.installations ?? []).filter((i) => i.app_slug === HAYSTACK_APP_SLUG);
702
+ return data.installations ?? [];
664
703
  }
665
704
  function tryOpenBrowser(url) {
666
705
  // Best-effort browser open. Never blocks setup — the URL is also printed.
@@ -687,16 +726,24 @@ function tryOpenBrowser(url) {
687
726
  return false;
688
727
  }
689
728
  }
729
+ /** True if the App is installed; tolerates transient errors (returns false so
730
+ * the poll keeps waiting) but rethrows a genuine auth failure so the caller can
731
+ * bail and prompt re-auth. */
732
+ async function isAppInstalled(token) {
733
+ return (await fetchHaystackInstallations(token)).length > 0;
734
+ }
690
735
  async function stepEnsureAppInstalled(token) {
691
736
  console.log(chalk.bold(' Step 0: Verify GitHub App'));
692
- let installed;
737
+ // Initial check — distinguish a genuine auth failure (bail, prompt re-auth)
738
+ // from a transient probe error (refuse-to-fail and proceed).
693
739
  try {
694
- installed = await fetchHaystackInstallations(token);
740
+ if (await isAppInstalled(token)) {
741
+ console.log(chalk.green(' ✓ Haystack App installed\n'));
742
+ return;
743
+ }
695
744
  }
696
745
  catch (err) {
697
746
  if (err instanceof InstallationsAuthError) {
698
- // Token is dead — retrying won't help. Prompt re-auth and exit so the
699
- // user doesn't waste a 10-minute poll on something that will never work.
700
747
  console.log(chalk.yellow(`\n Your GitHub token is no longer valid (${err.status}).`));
701
748
  console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
702
749
  trackError('haystack_setup_installations_auth_failure', { status: err.status });
@@ -704,81 +751,47 @@ async function stepEnsureAppInstalled(token) {
704
751
  }
705
752
  console.log(chalk.yellow(` Could not check App installations: ${err.message}`));
706
753
  console.log(chalk.dim(` Continuing — install manually at ${HAYSTACK_APP_INSTALL_URL} if not yet installed.\n`));
707
- // Probe failed but we proceed (refuse-to-fail UX so a transient blip can't
708
- // brick setup). Still log so we can see if probes start failing
709
- // systemically across users.
710
- trackError('haystack_setup_installations_probe_failed', {
711
- error_message: err.message,
712
- });
713
- return;
714
- }
715
- if (installed.length > 0) {
716
- const accounts = installed.map((i) => i.account?.login).filter(Boolean).join(', ');
717
- console.log(chalk.green(` ✓ Haystack App installed${accounts ? ` (${accounts})` : ''}\n`));
754
+ trackError('haystack_setup_installations_probe_failed', { error_message: err.message });
718
755
  return;
719
756
  }
720
757
  console.log(chalk.yellow(' The Haystack GitHub App is not installed yet.'));
721
- console.log(chalk.dim(' Without it, Haystack can\'t analyze, triage, or merge your PRs.'));
722
- console.log('');
723
- console.log(` Install at: ${chalk.cyan(HAYSTACK_APP_INSTALL_URL)}`);
724
- const opened = tryOpenBrowser(HAYSTACK_APP_INSTALL_URL);
725
- if (opened) {
726
- console.log(chalk.dim(' (Opened in your browser. Pick the repos to grant access.)'));
758
+ console.log(chalk.dim(" Without it, Haystack can't analyze, triage, or merge your PRs."));
759
+ // Only auto-open a browser for a local interactive user. Under a coding agent
760
+ // (JSON mode) the `action` event carries the URL for the agent to relay.
761
+ if (ui.interactive) {
762
+ const opened = tryOpenBrowser(HAYSTACK_APP_INSTALL_URL);
763
+ if (opened)
764
+ console.log(chalk.dim(' (Opened in your browser. Pick the repos to grant access.)'));
765
+ }
766
+ // ui.action emits an `action` event (JSON) or prints + spins (TTY), then polls
767
+ // until the App is detected or it times out. A mid-poll auth failure surfaces
768
+ // as a thrown InstallationsAuthError.
769
+ let installed = false;
770
+ try {
771
+ installed = await ui.action({
772
+ id: 'install_app',
773
+ message: 'Install the Haystack GitHub App, then it is detected automatically.',
774
+ url: HAYSTACK_APP_INSTALL_URL,
775
+ check: () => isAppInstalled(token),
776
+ pollIntervalMs: APP_INSTALL_POLL_INTERVAL_MS,
777
+ timeoutMs: APP_INSTALL_TIMEOUT_MS,
778
+ });
727
779
  }
728
- console.log('');
729
- console.log(chalk.dim(' Waiting for install to complete...'));
730
- const startedAt = Date.now();
731
- let consecutiveFailures = 0;
732
- let lastError = null;
733
- while (Date.now() - startedAt < APP_INSTALL_TIMEOUT_MS) {
734
- await new Promise((resolve) => setTimeout(resolve, APP_INSTALL_POLL_INTERVAL_MS));
735
- let current;
736
- try {
737
- current = await fetchHaystackInstallations(token);
738
- consecutiveFailures = 0;
739
- lastError = null;
740
- }
741
- catch (err) {
742
- // Auth errors during the poll loop mean the token went bad mid-flow
743
- // (e.g. user revoked it from GitHub settings). Same response as the
744
- // initial check — bail with re-auth guidance instead of grinding for
745
- // 10 minutes against a permanent failure.
746
- if (err instanceof InstallationsAuthError) {
747
- console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
748
- console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
749
- trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
750
- process.exit(1);
751
- }
752
- // Per-tick failures are usually transient (token refresh race, brief
753
- // GitHub API blip). Log the first one quietly so the user sees we're
754
- // not silently spinning, but don't spam the terminal every 3s.
755
- consecutiveFailures += 1;
756
- lastError = err instanceof Error ? err.message : String(err);
757
- if (consecutiveFailures === 1) {
758
- console.log(chalk.dim(` (Transient error checking installations: ${lastError} — retrying...)`));
759
- }
760
- continue;
761
- }
762
- if (current.length > 0) {
763
- const accounts = current.map((i) => i.account?.login).filter(Boolean).join(', ');
764
- console.log(chalk.green(`\n ✓ App installed${accounts ? ` (${accounts})` : ''}\n`));
765
- return;
780
+ catch (err) {
781
+ if (err instanceof InstallationsAuthError) {
782
+ console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
783
+ console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
784
+ trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
785
+ process.exit(1);
766
786
  }
787
+ throw err;
767
788
  }
768
- // Timeout is a real failure — emit a structured telemetry event so we can
769
- // route it to ops alerting (Slack via the existing trackError → PostHog
770
- // pipeline). Include whether the polling itself was failing (lastError
771
- // non-null after the final tick) so we can tell "user didn't click install"
772
- // apart from "GitHub API was down."
773
- trackError('haystack_setup_app_install_timeout', {
774
- consecutive_failures: consecutiveFailures,
775
- last_error: lastError,
776
- timeout_ms: APP_INSTALL_TIMEOUT_MS,
777
- });
778
- console.log(chalk.yellow('\n Timed out waiting for App install.'));
779
- if (lastError) {
780
- console.log(chalk.dim(` Last error checking installations: ${lastError}`));
789
+ if (installed) {
790
+ console.log(chalk.green('\n ✓ App installed\n'));
791
+ return;
781
792
  }
793
+ trackError('haystack_setup_app_install_timeout', { timeout_ms: APP_INSTALL_TIMEOUT_MS });
794
+ console.log(chalk.yellow('\n Timed out waiting for App install.'));
782
795
  console.log(chalk.dim(` Install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
783
796
  process.exit(1);
784
797
  }
@@ -790,22 +803,15 @@ async function stepSelectRepos(token) {
790
803
  console.log(chalk.yellow('\n No repositories found.\n'));
791
804
  process.exit(1);
792
805
  }
793
- // Clear the "Fetching" message
794
- process.stdout.write('\x1b[1A\x1b[K');
795
- const { selectedRepos } = await inquirer.prompt([
796
- {
797
- type: 'checkbox',
798
- name: 'selectedRepos',
799
- message: 'Select repositories to configure:',
800
- choices: repos.map((r) => ({ name: r, value: r })),
801
- pageSize: 15,
802
- validate: (answer) => {
803
- if (answer.length === 0)
804
- return 'Select at least one repository.';
805
- return true;
806
- },
807
- },
808
- ]);
806
+ const selectedRepos = await ui.multiselect({
807
+ id: 'select_repos',
808
+ message: 'Select repositories to configure:',
809
+ choices: repos.map((r) => ({ name: r, value: r })),
810
+ });
811
+ if (selectedRepos.length === 0) {
812
+ console.log(chalk.yellow('\n No repositories selected.\n'));
813
+ process.exit(1);
814
+ }
809
815
  console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
810
816
  return selectedRepos;
811
817
  }
@@ -879,14 +885,11 @@ async function stepReview(byRepo) {
879
885
  result.instructions.forEach((ins, i) => printInstruction(ins, i));
880
886
  }
881
887
  }
882
- const { wantToToggle } = await inquirer.prompt([
883
- {
884
- type: 'confirm',
885
- name: 'wantToToggle',
886
- message: 'Would you like to toggle any items?',
887
- default: false,
888
- },
889
- ]);
888
+ const wantToToggle = await ui.confirm({
889
+ id: 'want_to_toggle',
890
+ message: 'Would you like to toggle any items?',
891
+ default: false,
892
+ });
890
893
  if (!wantToToggle)
891
894
  return;
892
895
  // NUL separates repo / kind / id in the checkbox value so it can't collide
@@ -896,7 +899,7 @@ async function stepReview(byRepo) {
896
899
  for (const [repo, result] of byRepo) {
897
900
  if (result.rules.length + result.policies.length + result.instructions.length === 0)
898
901
  continue;
899
- allItems.push(new inquirer.Separator(`── ${repo} ──`));
902
+ allItems.push({ separator: `── ${repo} ──` });
900
903
  result.rules.forEach((r) => {
901
904
  allItems.push({
902
905
  name: `${r.name} ${chalk.dim(`[rule · ${CATEGORY_LABELS[r.category] || r.category}]`)}`,
@@ -919,15 +922,11 @@ async function stepReview(byRepo) {
919
922
  });
920
923
  });
921
924
  }
922
- const { enabled } = await inquirer.prompt([
923
- {
924
- type: 'checkbox',
925
- name: 'enabled',
926
- message: 'Select items to enable:',
927
- choices: allItems,
928
- pageSize: 20,
929
- },
930
- ]);
925
+ const enabled = await ui.multiselect({
926
+ id: 'review_enabled_items',
927
+ message: 'Select items to enable:',
928
+ choices: allItems,
929
+ });
931
930
  const enabledSet = new Set(enabled);
932
931
  for (const [repo, result] of byRepo) {
933
932
  result.rules.forEach((r) => { r.enabled = enabledSet.has(`${repo}${SEP}rule${SEP}${r.id}`); });
@@ -1083,22 +1082,16 @@ async function stepConfirm(byRepo, token) {
1083
1082
  // + the repo-level preferences.auto_merge opt-in model). When off, the
1084
1083
  // bootstrap PR is opened but NOT labeled, so the user merges it manually and
1085
1084
  // no PRs auto-merge.
1086
- const { autoMerge } = await inquirer.prompt([
1087
- {
1088
- type: 'confirm',
1089
- name: 'autoMerge',
1090
- message: 'Enable auto-merge? (Haystack merges PRs automatically once they pass review)',
1091
- default: true,
1092
- },
1093
- ]);
1094
- const { confirmed } = await inquirer.prompt([
1095
- {
1096
- type: 'confirm',
1097
- name: 'confirmed',
1098
- message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
1099
- default: true,
1100
- },
1101
- ]);
1085
+ const autoMerge = await ui.confirm({
1086
+ id: 'auto_merge',
1087
+ message: 'Enable auto-merge? (Haystack merges PRs automatically once they pass review)',
1088
+ default: true,
1089
+ });
1090
+ const confirmed = await ui.confirm({
1091
+ id: 'confirm_write',
1092
+ message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
1093
+ default: true,
1094
+ });
1102
1095
  if (!confirmed) {
1103
1096
  console.log(chalk.yellow('\n Setup cancelled.\n'));
1104
1097
  return { confirmed: false, bootstrapPRs: new Map() };
@@ -1110,9 +1103,9 @@ async function stepConfirm(byRepo, token) {
1110
1103
  const result = byRepo.get(repoFullName);
1111
1104
  const files = buildOnboardingFiles(result, autoMerge);
1112
1105
  try {
1113
- process.stdout.write(chalk.dim(` Writing to ${repoFullName}...`));
1106
+ ui.progress(`Writing to ${repoFullName}...`);
1114
1107
  const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
1115
- process.stdout.write('\r\x1b[K');
1108
+ ui.clearProgress();
1116
1109
  if (writeResult.bootstrapPR) {
1117
1110
  // Default branch was protected — config landed in a bootstrap PR. With
1118
1111
  // auto-merge on, Haystack merges it once its labels are processed; with
@@ -1130,7 +1123,7 @@ async function stepConfirm(byRepo, token) {
1130
1123
  }
1131
1124
  }
1132
1125
  catch (err) {
1133
- process.stdout.write('\r\x1b[K');
1126
+ ui.clearProgress();
1134
1127
  console.log(chalk.red(` ✗ ${repoFullName}: ${err.message}`));
1135
1128
  failedRepos.push(repoFullName);
1136
1129
  }
@@ -1145,7 +1138,18 @@ async function stepConfirm(byRepo, token) {
1145
1138
  // =============================================================================
1146
1139
  // Main command
1147
1140
  // =============================================================================
1148
- export async function setupCommand() {
1141
+ export async function setupCommand(options = {}) {
1142
+ // Create the prompter FIRST: in JSON mode it redirects console.* to stderr so
1143
+ // stdout carries only NDJSON, which the steps below rely on.
1144
+ ui = createPrompter({ json: options.json, answers: buildAnswerMap(options) });
1145
+ try {
1146
+ await runSetupFlow(options);
1147
+ }
1148
+ finally {
1149
+ ui.close();
1150
+ }
1151
+ }
1152
+ async function runSetupFlow(options) {
1149
1153
  console.log(chalk.cyan('\n Haystack Setup Wizard\n'));
1150
1154
  // Step 0: Check login
1151
1155
  let authContext;
@@ -1182,12 +1186,27 @@ export async function setupCommand() {
1182
1186
  await stepReview(byRepo);
1183
1187
  // Step 4: Confirm & write the config files
1184
1188
  const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token);
1185
- if (!confirmed)
1189
+ if (!confirmed) {
1190
+ ui.result({ status: 'cancelled' });
1186
1191
  return;
1192
+ }
1187
1193
  // Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
1188
1194
  // which repos had a protected default branch so it can commit
1189
1195
  // .entire/settings.json to the same PR instead of a doomed direct PUT.
1190
- await stepInstallEntire(selectedRepos, token, bootstrapPRs);
1196
+ // --skip-entire skips the whole step (binary install, hooks, AND the remote
1197
+ // .entire/settings.json write) — not just the install prompt.
1198
+ if (options.skipEntire) {
1199
+ console.log(chalk.dim(' Skipping session tracking (--skip-entire).\n'));
1200
+ }
1201
+ else {
1202
+ await stepInstallEntire(selectedRepos, token, bootstrapPRs);
1203
+ }
1204
+ // Final structured outcome for non-interactive/agent callers.
1205
+ ui.result({
1206
+ status: 'complete',
1207
+ repos: selectedRepos,
1208
+ pullRequests: [...bootstrapPRs.entries()].map(([repo, pr]) => ({ repo, url: pr.prUrl })),
1209
+ });
1191
1210
  }
1192
1211
  // =============================================================================
1193
1212
  // Step 7: Install Entire CLI for session tracking
@@ -1330,14 +1349,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1330
1349
  }
1331
1350
  else if (status.installed) {
1332
1351
  console.log(chalk.dim(` Stock Entire CLI detected: ${status.version.split('\n')[0]}`));
1333
- const { upgrade } = await inquirer.prompt([
1334
- {
1335
- type: 'confirm',
1336
- name: 'upgrade',
1337
- message: 'Upgrade to Haystack fork? (adds transcript optimization — saves GB of git storage)',
1338
- default: true,
1339
- },
1340
- ]);
1352
+ const upgrade = await ui.confirm({
1353
+ id: 'upgrade_entire',
1354
+ message: 'Upgrade to Haystack fork? (adds transcript optimization — saves GB of git storage)',
1355
+ default: true,
1356
+ });
1341
1357
  if (upgrade) {
1342
1358
  binaryReady = await installEntireBinary();
1343
1359
  }
@@ -1346,14 +1362,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1346
1362
  }
1347
1363
  }
1348
1364
  else {
1349
- const { install } = await inquirer.prompt([
1350
- {
1351
- type: 'confirm',
1352
- name: 'install',
1353
- message: 'Install Entire CLI? (tracks AI coding sessions for analysis)',
1354
- default: true,
1355
- },
1356
- ]);
1365
+ const install = await ui.confirm({
1366
+ id: 'install_entire',
1367
+ message: 'Install Entire CLI? (tracks AI coding sessions for analysis)',
1368
+ default: true,
1369
+ });
1357
1370
  if (install) {
1358
1371
  binaryReady = await installEntireBinary();
1359
1372
  }
@@ -1392,14 +1405,14 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1392
1405
  continue;
1393
1406
  }
1394
1407
  try {
1395
- process.stdout.write(chalk.dim(` Configuring ${repoFullName}...`));
1408
+ ui.progress(`Configuring ${repoFullName}...`);
1396
1409
  await configureEntireSettingsViaAPI(repoFullName, token);
1397
- process.stdout.write('\r\x1b[K');
1410
+ ui.clearProgress();
1398
1411
  console.log(chalk.green(` ✓ ${repoFullName}`));
1399
1412
  configured++;
1400
1413
  }
1401
1414
  catch (err) {
1402
- process.stdout.write('\r\x1b[K');
1415
+ ui.clearProgress();
1403
1416
  console.log(chalk.yellow(` ⚠ ${repoFullName}: ${err instanceof Error ? err.message : err}`));
1404
1417
  }
1405
1418
  }
@@ -1422,14 +1435,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1422
1435
  });
1423
1436
  if (repoName) {
1424
1437
  try {
1425
- const { installHooks } = await inquirer.prompt([
1426
- {
1427
- type: 'confirm',
1428
- name: 'installHooks',
1429
- message: `Install git hooks locally for ${repoName}? (agent detection, truncation checking, LLM rules)`,
1430
- default: true,
1431
- },
1432
- ]);
1438
+ const installHooks = await ui.confirm({
1439
+ id: 'install_hooks',
1440
+ message: `Install git hooks locally for ${repoName}? (agent detection, truncation checking, LLM rules)`,
1441
+ default: true,
1442
+ });
1433
1443
  if (installHooks) {
1434
1444
  console.log('');
1435
1445
  await hooksInstall({ skipEntire: true, force: true });
package/dist/index.js CHANGED
@@ -74,23 +74,48 @@ CI signals, and review policies.
74
74
  .action(initCommand);
75
75
  program
76
76
  .command('setup')
77
- .description('Interactive onboarding wizard — scan repos for rules, signals, and policies')
77
+ .description('Onboarding wizard — scan repos for rules/policies and write Haystack config')
78
+ .option('--json', 'Agent mode: emit NDJSON events on stdout, read answers on stdin (no TTY prompts)')
79
+ .option('--repo <repo...>', 'Repo(s) to configure (owner/name); skips the repo picker')
80
+ .option('-y, --yes', 'Accept defaults non-interactively (keep all discovered items, confirm write)')
81
+ // Defining --no-auto-merge also accepts --auto-merge; default is "ask" unless
82
+ // one is explicitly passed (detected via getOptionValueSource below).
83
+ .option('--no-auto-merge', 'Set auto-merge in the written config (--auto-merge / --no-auto-merge; omit to be asked)')
84
+ .option('--skip-entire', 'Skip the Entire CLI / git-hooks (session tracking) step')
85
+ .option('--answers <file>', 'JSON file of {questionId: value} pre-supplied answers')
78
86
  .addHelpText('after', `
79
- This walks you through Haystack setup step by step:
87
+ Steps: verify GitHub App select repos scan (rules/policies/instructions) →
88
+ review → write .haystack/pr-rules.yml + review-policy.md + .haystack.json.
80
89
 
81
- 1. Select GitHub repositories to configure
82
- 2. Scan for coding rules (conventions your team follows)
83
- 3. Scan for CI/bot signals (checks to wait for before merging)
84
- 4. Scan for review policies (who should review what)
85
- 5. Review and toggle discovered items
86
- 6. Write .haystack.json to your repos
90
+ Three ways to run:
91
+ Interactive (default): prompts in your terminal.
92
+ Autonomous/CI: supply every decision via flags, e.g.
93
+ haystack setup --repo owner/name --yes --no-auto-merge --skip-entire
94
+ Coding agent: --json speaks NDJSON on stdout and reads answers on stdin, so an
95
+ agent can auto-answer or relay questions to the user. Pre-supplied answers
96
+ (flags / --answers) skip the matching question.
87
97
 
88
98
  Requires authentication — run \`haystack login\` first.
89
99
 
90
100
  Examples:
91
101
  haystack setup
102
+ haystack setup --repo owner/name --yes
103
+ haystack setup --json --repo owner/name
92
104
  `)
93
- .action(setupCommand);
105
+ .action((options, command) => {
106
+ // Tri-state auto-merge: undefined (ask) unless --auto-merge/--no-auto-merge
107
+ // was explicitly passed. commander defaults autoMerge to true because of the
108
+ // --no- form, so check the value source rather than the value.
109
+ const autoMergeExplicit = command.getOptionValueSource('autoMerge') === 'cli';
110
+ return setupCommand({
111
+ json: options.json,
112
+ repos: options.repo,
113
+ yes: options.yes,
114
+ autoMerge: autoMergeExplicit ? options.autoMerge : undefined,
115
+ skipEntire: options.skipEntire,
116
+ answersFile: options.answers,
117
+ });
118
+ });
94
119
  program
95
120
  .command('status')
96
121
  .description('Check if .haystack.json exists and is valid')
@@ -45,7 +45,7 @@ const INTENT_DRIFT_SCHEMA = `{
45
45
  "line": 42,
46
46
  "severity": "error | warning | info",
47
47
  "message": "Description of the drift or incomplete fulfillment",
48
- "pattern": "intent_drift | incomplete_fulfillment | scope_creep"
48
+ "pattern": "intent_drift | incomplete_fulfillment | scope_creep | unspecified_decision | ignored_correction | weakened_posture | claimed_but_not_done"
49
49
  }
50
50
  ],
51
51
  "summary": "Brief 1-sentence summary",
@@ -286,6 +286,36 @@ The agent added functionality the user never asked for:
286
286
  - User asks for one feature → agent bundles in extra features "while we're at it"
287
287
  - Any new mechanism not traceable to a user instruction
288
288
 
289
+ ### Unspecified Decision
290
+ The user authorized the task's GOAL, but the agent made a specific decision in HOW it carried it out that shapes the program's end output, externally observable behavior, or the data end-users/callers see — and the user never specified or approved that particular decision, and a reasonable user would plausibly want a say in it. Examples (general — do NOT pattern-match on specific keywords):
291
+ - Silently dropping, capping, sampling, or transforming data that flows to the output
292
+ - Picking a default that determines what end-users see
293
+ - Resolving an ambiguous requirement one way when other materially different behaviors were equally valid
294
+ - Choosing a fixed value where the choice changes results
295
+ Do NOT flag internal implementation choices with no observable effect (naming, file layout, helper structure), decisions forced by correctness, or cosmetic defaults a user would not care about. When unsure whether a user would care, do not flag it — reserve this for decisions with real, user-visible ramifications. The difference from scope creep: scope creep is an unrequested *flow*; an unspecified decision is an unrequested, output-shaping choice *inside a requested flow*.
296
+
297
+ ### Ignored Correction
298
+ The user gave an explicit correction or redirection and the final code does NOT honor it:
299
+ - User said "don't use a global / use X instead / that's racy, do Y" and the agent shipped the thing it was told not to
300
+ - Agent applied the correction, then quietly reverted it in a later step
301
+ - A general later "looks good" does NOT cancel a specific earlier correction
302
+ This is high severity by default: the user actively steered and was overridden.
303
+
304
+ ### Weakened Posture
305
+ In service of an authorized goal, the agent relaxed or removed a security, safety, or correctness guard the user never asked to weaken:
306
+ - Loosened an auth/permission/validation check, or broadened access (CORS, scopes)
307
+ - Swallowed or silenced an error, removed an assertion/guard, hardcoded a bypass or credential
308
+ - Disabled a test (.skip / xit), suppressed a type or lint check (any, @ts-ignore, disable comments)
309
+ - Lowered a threshold/timeout that existed as a safeguard
310
+ High severity by default. (If the user explicitly asked to remove the guard, it is not a finding.)
311
+
312
+ ### Claimed But Not Done
313
+ The agent told the user it completed work that the diff does not actually contain, or contains only in a materially weaker form:
314
+ - "I added error handling for timeouts" but no timeout handling exists in the diff
315
+ - "Added tests for the edge case" but no test assertions exercise it
316
+ - "Made the limit configurable" but the value is still hardcoded
317
+ Compare each concrete claim the agent made to the user against what the diff actually shows. Only flag when the diff clearly contradicts or fails to support the claim; when unsure, do not flag.
318
+
289
319
  ## Red flags to search for in the diff
290
320
 
291
321
  - Fixed/hardcoded values where dynamic behavior was requested
@@ -293,6 +323,7 @@ The agent added functionality the user never asked for:
293
323
  - Empty function bodies or early returns
294
324
  - Interface fields that are declared but never assigned anywhere
295
325
  - New mechanisms (cooldowns, retries, caches, rate limits) not requested by the user
326
+ - Decisions that drop, limit, or reshape data flowing to the output, or pick a default that changes what end-users see, with no instruction specifying it
296
327
 
297
328
  ## Output
298
329
 
@@ -303,9 +334,9 @@ ${INTENT_DRIFT_SCHEMA}
303
334
  \`\`\`
304
335
 
305
336
  - Set \`sessionsChecked\` to the number of trace files you read
306
- - Set \`passed\` to \`true\` if no drift, incomplete fulfillment, or scope creep found
307
- - Set \`passed\` to \`false\` if any issues with severity "error" were found
308
- - For each issue, set \`pattern\` to "intent_drift", "incomplete_fulfillment", or "scope_creep"
337
+ - Set \`passed\` to \`true\` only when no intent-drift issues are found
338
+ - Set \`passed\` to \`false\` when any issue is found (any pattern, any severity)
339
+ - For each issue, set \`pattern\` to one of: "intent_drift", "incomplete_fulfillment", "scope_creep", "unspecified_decision", "ignored_correction", "weakened_posture", "claimed_but_not_done"
309
340
  - You MUST write the result file even if no issues are found
310
341
 
311
342
  ## Severity guidelines
@@ -13,7 +13,7 @@ export interface TriageIssue {
13
13
  /** For rules validator: rule ID (e.g., PR001) */
14
14
  rule?: string;
15
15
  /** For intent drift: drift pattern */
16
- pattern?: 'intent_drift' | 'incomplete_fulfillment' | 'scope_creep';
16
+ pattern?: 'intent_drift' | 'incomplete_fulfillment' | 'scope_creep' | 'unspecified_decision' | 'ignored_correction' | 'weakened_posture' | 'claimed_but_not_done';
17
17
  }
18
18
  export interface CodeReviewResult {
19
19
  checker: 'code-review';
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Prompter — decouples a wizard's *decisions* from *how they're answered*.
3
+ *
4
+ * `haystack setup` historically welded every decision to inquirer/TTY, so it
5
+ * couldn't run headless, in CI, or under a coding agent. The Prompter is the
6
+ * seam: step code calls `confirm` / `multiselect` / `action`, and the
7
+ * implementation decides how to source the answer.
8
+ *
9
+ * Three modes, one mechanism:
10
+ * - TtyPrompter — humans in a terminal (inquirer). Default.
11
+ * - JsonPrompter — a coding agent: NDJSON events on stdout, answers on stdin.
12
+ * The agent can relay a `question` to the user OR auto-answer.
13
+ * - Pre-supplied answers (flags / --answers file) short-circuit any question
14
+ * by id, so the same code runs fully autonomously when every answer is known.
15
+ *
16
+ * Human-facing chrome stays as `console.*`; in JSON mode the JsonPrompter
17
+ * redirects `console.*` to stderr so stdout carries only NDJSON.
18
+ */
19
+ /** A selectable option. A `{ separator }` entry renders a non-selectable group
20
+ * header in TTY mode and is ignored in JSON mode. */
21
+ export type Choice<T> = {
22
+ name: string;
23
+ value: T;
24
+ checked?: boolean;
25
+ } | {
26
+ separator: string;
27
+ };
28
+ export interface Prompter {
29
+ /** True for the human TTY prompter — gates side effects like opening a browser
30
+ * that only make sense for a local interactive user. */
31
+ readonly interactive: boolean;
32
+ /** Yes/no decision. */
33
+ confirm(q: {
34
+ id: string;
35
+ message: string;
36
+ default?: boolean;
37
+ }): Promise<boolean>;
38
+ /** Pick zero-or-more values. Items default-checked via `checked: true`. */
39
+ multiselect<T>(q: {
40
+ id: string;
41
+ message: string;
42
+ choices: Choice<T>[];
43
+ }): Promise<T[]>;
44
+ /**
45
+ * An action the user must take out-of-band (e.g. install the GitHub App).
46
+ * Polls `check()` until it returns true (satisfied) or `timeoutMs` elapses.
47
+ * A pre-supplied / stdin answer of "skip" resolves false without polling.
48
+ * Returns true when satisfied, false when skipped or timed out.
49
+ */
50
+ action(q: {
51
+ id: string;
52
+ message: string;
53
+ url?: string;
54
+ check: () => Promise<boolean>;
55
+ pollIntervalMs?: number;
56
+ timeoutMs?: number;
57
+ }): Promise<boolean>;
58
+ /** Transient progress line (scan ticker, per-repo write status). */
59
+ progress(message: string, detail?: string): void;
60
+ /** Clear the current progress line. */
61
+ clearProgress(): void;
62
+ /** Final structured outcome (emitted as a `result` event in JSON mode). */
63
+ result(data: Record<string, unknown>): void;
64
+ /** Release stdin/readline so the process can exit. */
65
+ close(): void;
66
+ }
67
+ /** Answers known up-front (from flags or an --answers file), keyed by question id. */
68
+ export type AnswerMap = Record<string, unknown>;
69
+ export declare function loadAnswersFile(path: string): AnswerMap;
70
+ export interface PrompterOptions {
71
+ json?: boolean;
72
+ answers?: AnswerMap;
73
+ }
74
+ export declare function createPrompter(options?: PrompterOptions): Prompter;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Prompter — decouples a wizard's *decisions* from *how they're answered*.
3
+ *
4
+ * `haystack setup` historically welded every decision to inquirer/TTY, so it
5
+ * couldn't run headless, in CI, or under a coding agent. The Prompter is the
6
+ * seam: step code calls `confirm` / `multiselect` / `action`, and the
7
+ * implementation decides how to source the answer.
8
+ *
9
+ * Three modes, one mechanism:
10
+ * - TtyPrompter — humans in a terminal (inquirer). Default.
11
+ * - JsonPrompter — a coding agent: NDJSON events on stdout, answers on stdin.
12
+ * The agent can relay a `question` to the user OR auto-answer.
13
+ * - Pre-supplied answers (flags / --answers file) short-circuit any question
14
+ * by id, so the same code runs fully autonomously when every answer is known.
15
+ *
16
+ * Human-facing chrome stays as `console.*`; in JSON mode the JsonPrompter
17
+ * redirects `console.*` to stderr so stdout carries only NDJSON.
18
+ */
19
+ import chalk from 'chalk';
20
+ import inquirer from 'inquirer';
21
+ import * as readline from 'node:readline';
22
+ import { readFileSync } from 'node:fs';
23
+ function isSeparator(c) {
24
+ return c.separator !== undefined;
25
+ }
26
+ export function loadAnswersFile(path) {
27
+ const raw = readFileSync(path, 'utf8');
28
+ const parsed = JSON.parse(raw);
29
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
30
+ throw new Error(`--answers file must be a JSON object of {questionId: value}`);
31
+ }
32
+ return parsed;
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // TTY (human) prompter
36
+ // ---------------------------------------------------------------------------
37
+ class TtyPrompter {
38
+ answers;
39
+ interactive = true;
40
+ constructor(answers) {
41
+ this.answers = answers;
42
+ }
43
+ async confirm(q) {
44
+ if (q.id in this.answers)
45
+ return Boolean(this.answers[q.id]);
46
+ const { value } = await inquirer.prompt([
47
+ { type: 'confirm', name: 'value', message: q.message, default: q.default ?? true },
48
+ ]);
49
+ return value;
50
+ }
51
+ async multiselect(q) {
52
+ if (q.id in this.answers)
53
+ return this.answers[q.id];
54
+ const choices = q.choices.map((c) => isSeparator(c) ? new inquirer.Separator(c.separator) : { name: c.name, value: c.value, checked: c.checked });
55
+ const { value } = await inquirer.prompt([
56
+ { type: 'checkbox', name: 'value', message: q.message, choices: choices, pageSize: 20 },
57
+ ]);
58
+ return value;
59
+ }
60
+ async action(q) {
61
+ if (this.answers[q.id] === 'skip')
62
+ return false;
63
+ if (await q.check())
64
+ return true;
65
+ console.log(chalk.yellow(` ${q.message}`));
66
+ if (q.url)
67
+ console.log(` ${chalk.cyan(q.url)}`);
68
+ console.log(chalk.dim(' Waiting...'));
69
+ const started = Date.now();
70
+ const timeout = q.timeoutMs ?? 10 * 60 * 1000;
71
+ const interval = q.pollIntervalMs ?? 3000;
72
+ while (Date.now() - started < timeout) {
73
+ await sleep(interval);
74
+ if (await q.check())
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+ progress(message, detail) {
80
+ process.stdout.write(`\r${chalk.dim(' ' + message)}${detail ? chalk.dim(' — ' + detail) : ''}\x1b[K`);
81
+ }
82
+ clearProgress() {
83
+ process.stdout.write('\r\x1b[K');
84
+ }
85
+ result(_data) {
86
+ // Human output is rendered inline by the step code; nothing structured to emit.
87
+ }
88
+ close() {
89
+ /* nothing to release */
90
+ }
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // JSON (coding-agent) prompter
94
+ // ---------------------------------------------------------------------------
95
+ class JsonPrompter {
96
+ answers;
97
+ interactive = false;
98
+ rl = null;
99
+ pending = new Map();
100
+ buffered = new Map();
101
+ restoreConsole = null;
102
+ constructor(answers) {
103
+ this.answers = answers;
104
+ // Keep stdout pure NDJSON: route human chrome to stderr.
105
+ const origLog = console.log;
106
+ const origWarn = console.warn;
107
+ const origError = console.error;
108
+ const toErr = (...args) => process.stderr.write(args.map(stringifyArg).join(' ') + '\n');
109
+ console.log = toErr;
110
+ console.warn = toErr;
111
+ console.error = toErr;
112
+ this.restoreConsole = () => {
113
+ console.log = origLog;
114
+ console.warn = origWarn;
115
+ console.error = origError;
116
+ };
117
+ }
118
+ emit(event) {
119
+ process.stdout.write(JSON.stringify(event) + '\n');
120
+ }
121
+ /** Lazily open stdin only when we actually need an answer the agent must send. */
122
+ ensureStdin() {
123
+ if (this.rl)
124
+ return;
125
+ this.rl = readline.createInterface({ input: process.stdin });
126
+ this.rl.on('line', (line) => {
127
+ const trimmed = line.trim();
128
+ if (!trimmed)
129
+ return;
130
+ let obj;
131
+ try {
132
+ obj = JSON.parse(trimmed);
133
+ }
134
+ catch {
135
+ return; // ignore non-JSON noise on stdin
136
+ }
137
+ if (!obj || typeof obj.id !== 'string')
138
+ return;
139
+ const resolve = this.pending.get(obj.id);
140
+ if (resolve) {
141
+ this.pending.delete(obj.id);
142
+ resolve(obj.value);
143
+ }
144
+ else {
145
+ this.buffered.set(obj.id, obj.value);
146
+ }
147
+ });
148
+ }
149
+ awaitAnswer(id) {
150
+ if (this.buffered.has(id)) {
151
+ const v = this.buffered.get(id);
152
+ this.buffered.delete(id);
153
+ return Promise.resolve(v);
154
+ }
155
+ this.ensureStdin();
156
+ return new Promise((resolve) => this.pending.set(id, resolve));
157
+ }
158
+ async confirm(q) {
159
+ if (q.id in this.answers)
160
+ return Boolean(this.answers[q.id]);
161
+ this.emit({ type: 'question', id: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
162
+ return Boolean(await this.awaitAnswer(q.id));
163
+ }
164
+ async multiselect(q) {
165
+ if (q.id in this.answers)
166
+ return this.answers[q.id];
167
+ const options = q.choices
168
+ .filter((c) => !isSeparator(c))
169
+ .map((c) => ({ value: c.value, label: c.name, default: c.checked ?? false }));
170
+ this.emit({ type: 'question', id: q.id, kind: 'multiselect', prompt: q.message, options });
171
+ return (await this.awaitAnswer(q.id));
172
+ }
173
+ async action(q) {
174
+ if (this.answers[q.id] === 'skip')
175
+ return false;
176
+ if (await q.check())
177
+ return true;
178
+ // Tell the agent an out-of-band action is needed; then poll until satisfied.
179
+ // An agent may also push {"id","value":"skip"|"done"} on stdin to override.
180
+ this.emit({ type: 'action', id: q.id, prompt: q.message, url: q.url, poll: true });
181
+ const started = Date.now();
182
+ const timeout = q.timeoutMs ?? 10 * 60 * 1000;
183
+ const interval = q.pollIntervalMs ?? 3000;
184
+ let override = null;
185
+ // Resolve the moment the agent answers, OR when polling detects completion.
186
+ this.ensureStdin();
187
+ const answerPromise = this.awaitAnswer(q.id).then((v) => {
188
+ override = v === 'skip' ? 'skip' : 'done';
189
+ });
190
+ while (Date.now() - started < timeout) {
191
+ if (await q.check())
192
+ return true;
193
+ if (override === 'skip')
194
+ return false;
195
+ if (override === 'done')
196
+ return (await q.check()); // agent says installed; trust but verify
197
+ await Promise.race([sleep(interval), answerPromise]);
198
+ }
199
+ return false;
200
+ }
201
+ progress(message, detail) {
202
+ this.emit({ type: 'progress', message, ...(detail ? { detail } : {}) });
203
+ }
204
+ clearProgress() {
205
+ /* no ephemeral line in JSON mode */
206
+ }
207
+ result(data) {
208
+ this.emit({ type: 'result', ...data });
209
+ }
210
+ close() {
211
+ this.rl?.close();
212
+ this.rl = null;
213
+ this.restoreConsole?.();
214
+ this.restoreConsole = null;
215
+ }
216
+ }
217
+ export function createPrompter(options = {}) {
218
+ const answers = options.answers ?? {};
219
+ return options.json ? new JsonPrompter(answers) : new TtyPrompter(answers);
220
+ }
221
+ function sleep(ms) {
222
+ return new Promise((resolve) => setTimeout(resolve, ms));
223
+ }
224
+ function stringifyArg(a) {
225
+ return typeof a === 'string' ? a : String(a);
226
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.14.5",
3
+ "version": "0.15.0",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {