@haystackeditor/cli 0.15.19 → 0.15.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +86 -1
  2. package/dist/assets/skills/map-your-system.md +9 -4
  3. package/dist/commands/ask.js +2 -2
  4. package/dist/commands/config.d.ts +9 -0
  5. package/dist/commands/config.js +46 -14
  6. package/dist/commands/dismiss.d.ts +15 -3
  7. package/dist/commands/dismiss.js +66 -116
  8. package/dist/commands/mcp.js +96 -1
  9. package/dist/commands/policy.js +31 -12
  10. package/dist/commands/pr-status.d.ts +125 -2
  11. package/dist/commands/pr-status.js +88 -73
  12. package/dist/commands/pr.d.ts +1 -7
  13. package/dist/commands/pr.js +10 -21
  14. package/dist/commands/request-review.d.ts +12 -1
  15. package/dist/commands/request-review.js +42 -65
  16. package/dist/commands/review.d.ts +1 -0
  17. package/dist/commands/review.js +58 -60
  18. package/dist/commands/setup.js +4 -1
  19. package/dist/commands/submit.d.ts +3 -0
  20. package/dist/commands/submit.js +178 -38
  21. package/dist/commands/system-map.d.ts +42 -0
  22. package/dist/commands/system-map.js +228 -0
  23. package/dist/commands/tokens.js +3 -2
  24. package/dist/commands/traces.js +2 -2
  25. package/dist/commands/triage.d.ts +1 -6
  26. package/dist/commands/triage.js +51 -55
  27. package/dist/index.js +88 -20
  28. package/dist/schema.d.ts +4 -0
  29. package/dist/schema.js +4 -0
  30. package/dist/states.d.ts +29 -0
  31. package/dist/states.js +46 -0
  32. package/dist/triage/runner.d.ts +7 -0
  33. package/dist/triage/runner.js +2 -2
  34. package/dist/utils/action-output.d.ts +24 -0
  35. package/dist/utils/action-output.js +26 -0
  36. package/dist/utils/analysis-api.d.ts +11 -0
  37. package/dist/utils/analysis-api.js +29 -3
  38. package/dist/utils/auth.js +7 -0
  39. package/dist/utils/git.d.ts +4 -1
  40. package/dist/utils/git.js +17 -7
  41. package/dist/utils/haystack-api.d.ts +15 -0
  42. package/dist/utils/haystack-api.js +58 -7
  43. package/dist/utils/pr-ref.d.ts +27 -0
  44. package/dist/utils/pr-ref.js +56 -0
  45. package/package.json +1 -1
  46. package/schemas/action.v1.json +22 -0
  47. package/schemas/error.v1.json +14 -0
  48. package/schemas/pr-status.v1.json +61 -0
  49. package/schemas/pr.v1.json +80 -17
  50. package/schemas/setup.v1.json +66 -12
  51. package/schemas/submit.v1.json +88 -0
  52. package/schemas/triage.v1.json +71 -18
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ import { dirname, join } from 'node:path';
25
25
  import chalk from 'chalk';
26
26
  import { Command, Option } from 'commander';
27
27
  import { statusCommand } from './commands/status.js';
28
+ import { withSchema } from './schema.js';
28
29
  import { initCommand } from './commands/init.js';
29
30
  import { authListCommand, authUseCommand, loginCommand, logoutCommand, whoamiCommand, } from './commands/login.js';
30
31
  import { handleAgenticTool, handleAutoMerge, handleAutoFix, handleWaitForReviewers, isAutoMergeEnabled, isAutoFixEnabled } from './commands/config.js';
@@ -46,6 +47,7 @@ import { setupCommand } from './commands/setup.js';
46
47
  import { schemaCommand, listSchemas } from './commands/schema-cmd.js';
47
48
  import { registerWebhook, listWebhooks, rotateWebhookSecret, setWebhookEnabled, listDeliveries, replayDelivery, } from './commands/webhooks.js';
48
49
  import { editRulesCommand, validateRulesCommand } from './commands/rules.js';
50
+ import { systemMapValidateCommand } from './commands/system-map.js';
49
51
  import { headlessLoginCommand, headlessLogoutCommand, listTokensCommand, revokeTokenCommand, } from './commands/tokens.js';
50
52
  // `mcp` is imported lazily inside its command action (below), NOT at the top level.
51
53
  // It is the only module that pulls in `@modelcontextprotocol/sdk`; keeping it out of
@@ -53,12 +55,19 @@ import { headlessLoginCommand, headlessLogoutCommand, listTokensCommand, revokeT
53
55
  // like `triage`/`submit`. (0.15.12 shipped `mcp.js` but published its package.json
54
56
  // WITHOUT `@modelcontextprotocol/sdk`, so the eager import here hard-crashed every
55
57
  // command at startup with ERR_MODULE_NOT_FOUND.)
56
- async function runPublicCommand(action) {
58
+ async function runPublicCommand(action, json) {
57
59
  try {
58
60
  await action();
59
61
  }
60
62
  catch (err) {
61
- console.error(chalk.red(err instanceof Error ? err.message : String(err)));
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ // In --json mode stdout must still carry one parseable document — emit the
65
+ // versioned error envelope (`haystack schema error`) instead of leaving
66
+ // stdout empty with prose on stderr.
67
+ if (json) {
68
+ process.stdout.write(JSON.stringify(withSchema('error', { status: 'error', error: message }), null, 2) + '\n');
69
+ }
70
+ console.error(chalk.red(message));
62
71
  process.exitCode = 1;
63
72
  }
64
73
  }
@@ -112,7 +121,7 @@ program
112
121
  .description('Onboarding wizard — scan repos for rules/policies and write Haystack config')
113
122
  .option('--json', 'Agent mode: emit NDJSON events on stdout, read answers on stdin (no TTY prompts)')
114
123
  .option('--repo <repo...>', 'Repo(s) to configure (owner/name); skips the repo picker')
115
- .option('-y, --yes', 'Accept defaults non-interactively (keep all discovered items, confirm write)')
124
+ .option('-y, --yes', 'Accept defaults non-interactively (keep all discovered items, confirm write, ENABLE auto-merge — pass --no-auto-merge to opt out)')
116
125
  // Defining --no-auto-merge also accepts --auto-merge; default is "ask" unless
117
126
  // one is explicitly passed (detected via getOptionValueSource below).
118
127
  .option('--no-auto-merge', 'Set auto-merge in the written config (--auto-merge / --no-auto-merge; omit to be asked)')
@@ -247,7 +256,9 @@ program
247
256
  .option('--force', 'Skip pre-PR triage checks')
248
257
  .addOption(new Option('--auto-fix', 'Alpha auto-fix for entitled repositories').hideHelp())
249
258
  .option('--auto-merge', 'Apply auto-merge label (default: from .haystack.json, --no-auto-merge to disable)')
259
+ .option('--no-auto-merge', 'Do not apply the auto-merge label')
250
260
  .option('--no-wait', 'Skip waiting for analysis results')
261
+ .option('--json', 'Machine-readable: one JSON document on stdout (see `haystack schema submit`), progress on stderr')
251
262
  .option('--max-turns <n>', 'Max agentic turns per triage checker (overrides .haystack.json triage.maxTurns)', (v) => parseInt(v, 10))
252
263
  .option('--triage-timeout <seconds>', 'Wall-clock timeout per triage checker in seconds (overrides .haystack.json triage.timeoutMs)', (v) => parseInt(v, 10))
253
264
  .addHelpText('after', `
@@ -296,8 +307,22 @@ Review Routing:
296
307
  needs-assignment queue (a teammate will pick it up)
297
308
  • --review <username> Same label, plus requests review from that GitHub user
298
309
 
310
+ Machine-readable output (--json):
311
+ stdout carries exactly one JSON document (schema: \`haystack schema submit\`);
312
+ all progress goes to stderr. On success the payload includes the PR ref,
313
+ the resolved title/body and their sources, auto-merge/auto-fix state with
314
+ source, and the analysis outcome. On failure it is an error envelope
315
+ { status: "error", error: "..." }.
316
+
317
+ Exit codes:
318
+ 0 PR created/updated (including verdict "needs_input" — the PR exists;
319
+ read the verdict from the JSON or from \`haystack triage\`)
320
+ 1 Hard failure (bad flags, auth, push, PR creation, blocking triage
321
+ findings, or the analysis status check itself failing)
322
+
299
323
  Examples:
300
324
  haystack submit # Recommended: triage, create PR, auto-merge queue
325
+ haystack submit --json # Agent mode: JSON on stdout, progress on stderr
301
326
  haystack submit --force # Skip triage
302
327
  haystack submit --no-wait # Don't wait for analysis
303
328
  haystack submit --title "Fix auth" # Custom PR title
@@ -312,17 +337,23 @@ Examples:
312
337
  haystack submit --max-turns 12 # Raise triage turn cap to 12 per checker
313
338
  haystack submit --triage-timeout 300 # Raise triage wall-clock to 5 minutes
314
339
  `)
315
- .action(async (options) => {
316
- // Resolve --auto-merge default from .haystack.json when not explicitly set
317
- if (options.autoMerge === undefined) {
340
+ .action(async (options, command) => {
341
+ // Resolve --auto-merge / --auto-fix defaults from .haystack.json when not
342
+ // explicitly set, and record WHERE each value came from so submit can
343
+ // announce the source (silent config-driven defaults are invisible to
344
+ // the caller otherwise).
345
+ const autoMergeFromCli = command.getOptionValueSource('autoMerge') === 'cli';
346
+ const autoFixFromCli = command.getOptionValueSource('autoFix') === 'cli';
347
+ if (!autoMergeFromCli) {
318
348
  options.autoMerge = await isAutoMergeEnabled();
319
349
  }
320
- // Resolve --auto-fix default from .haystack.json (preferences.auto_fix).
321
350
  // Auto-fix remains alpha — the explicit --auto-fix flag still surfaces
322
351
  // discouragement warnings; opting in via repo config is the supported path.
323
- if (options.autoFix === undefined) {
352
+ if (!autoFixFromCli) {
324
353
  options.autoFix = await isAutoFixEnabled();
325
354
  }
355
+ options.autoMergeSource = autoMergeFromCli ? 'flag' : 'config';
356
+ options.autoFixSource = autoFixFromCli ? 'flag' : 'config';
326
357
  return submitCommand(options);
327
358
  });
328
359
  program
@@ -364,35 +395,36 @@ inbox
364
395
  .command('list')
365
396
  .description('List PRs in your Haystack inbox')
366
397
  .option('--json', 'Output as JSON')
367
- .action((options) => runPublicCommand(() => inboxListCommand(options)));
398
+ .action((options) => runPublicCommand(() => inboxListCommand(options), options.json));
368
399
  const prProgram = program.command('pr').description('Inspect one pull request');
369
400
  prProgram
370
401
  .command('get <ref>')
371
402
  .description('Get triage, merge blockers, and trace availability')
372
403
  .option('--json', 'Output as JSON')
373
- .action((ref, options) => runPublicCommand(() => prReadCommand(ref, options)));
404
+ .action((ref, options) => runPublicCommand(() => prReadCommand(ref, options), options.json));
374
405
  program
375
406
  .command('ask <ref> <question>')
376
407
  .description('Ask Haystack about a pull request')
377
408
  .option('--json', 'Output the answer and every consulted customer-facing source as JSON')
378
409
  .option('--session <id>', 'Continue a previous Ask Haystack session')
379
- .action((ref, question, options) => runPublicCommand(() => askHaystackCommand(ref, question, options)));
410
+ .action((ref, question, options) => runPublicCommand(() => askHaystackCommand(ref, question, options), options.json));
380
411
  const traces = program.command('traces').description('Inspect customer-owned Entire traces');
381
412
  traces
382
413
  .command('list <ref>')
383
414
  .description('List retained checkpoints for a pull request')
384
415
  .option('--json', 'Output as JSON')
385
- .action((ref, options) => runPublicCommand(() => tracesListCommand(ref, options)));
416
+ .action((ref, options) => runPublicCommand(() => tracesListCommand(ref, options), options.json));
386
417
  traces
387
418
  .command('get <ref> <checkpoint>')
388
419
  .description('Get retained transcript chunks for one checkpoint')
389
420
  .option('--cursor <cursor>', 'Zero-based chunk cursor')
390
421
  .option('--limit <count>', 'Chunks per page', '20')
391
422
  .option('--json', 'Output as JSON')
392
- .action((ref, checkpoint, options) => runPublicCommand(() => tracesGetCommand(ref, checkpoint, options)));
423
+ .action((ref, checkpoint, options) => runPublicCommand(() => tracesGetCommand(ref, checkpoint, options), options.json));
393
424
  program
394
425
  .command('dismiss <pr>')
395
426
  .description('Dismiss analysis findings for a PR')
427
+ .option('--json', 'Machine-readable output (see `haystack schema action`)')
396
428
  .addHelpText('after', `
397
429
  Dismiss analysis findings for a PR, moving it from "Issues Found" to
398
430
  "Good to Merge" in the Haystack feed.
@@ -414,6 +446,7 @@ Examples:
414
446
  program
415
447
  .command('mark-reviewed <pr>')
416
448
  .description('Mark human review as not needed for a PR')
449
+ .option('--json', 'Machine-readable output (see `haystack schema action`)')
417
450
  .addHelpText('after', `
418
451
  Mark human review as not needed for a PR, moving it from "Needs Review"
419
452
  to "Good to Merge" in the Haystack feed.
@@ -435,6 +468,7 @@ Examples:
435
468
  program
436
469
  .command('undismiss <pr>')
437
470
  .description('Undo a dismiss or mark-reviewed override for a PR')
471
+ .option('--json', 'Machine-readable output (see `haystack schema action`)')
438
472
  .addHelpText('after', `
439
473
  Clear all overrides (dismissed findings and/or review-not-needed) for a PR,
440
474
  returning it to its original feed bucket.
@@ -452,8 +486,9 @@ Examples:
452
486
  .action(undismissCommand);
453
487
  program
454
488
  .command('review [pr]')
455
- .description('Trigger a fresh Haystack analysis for a PR')
489
+ .description('Trigger a fresh Haystack analysis for a PR (machine analysis — for HUMAN review use request-review)')
456
490
  .option('--no-wait', 'Exit after triggering instead of waiting for results')
491
+ .option('--json', 'Machine-readable output (see `haystack schema action`)')
457
492
  .addHelpText('after', `
458
493
  Re-run full Haystack analysis on a PR's current head.
459
494
 
@@ -481,6 +516,7 @@ Examples:
481
516
  program
482
517
  .command('request-review <pr> [reviewer]')
483
518
  .description('Tag a PR as needing human review')
519
+ .option('--json', 'Machine-readable output (see `haystack schema action`)')
484
520
  .addHelpText('after', `
485
521
  Tag any PR with "needs human review", even after it was created.
486
522
  Adds the haystack:needs-review label to the PR, moving it from
@@ -551,9 +587,9 @@ config
551
587
  .description('Auto-merge safe PRs submitted via haystack submit (on|off|status)')
552
588
  .addHelpText('after', `
553
589
  Actions:
554
- on, enable Enable auto-merge for safe PRs
555
- off, disable Disable auto-merge
556
- status Show current status (default)
590
+ on, enable, true Enable auto-merge for safe PRs
591
+ off, disable, false Disable auto-merge
592
+ status Show current status (default)
557
593
 
558
594
  When enabled, PRs submitted via \`haystack submit\` will be
559
595
  automatically merged if Haystack analysis finds no issues
@@ -571,9 +607,9 @@ config
571
607
  .description('(Alpha) Auto-fix mechanical issues on PRs submitted via haystack submit (on|off|status)')
572
608
  .addHelpText('after', `
573
609
  Actions:
574
- on, enable Enable auto-fix for mechanical issues
575
- off, disable Disable auto-fix
576
- status Show current status (default)
610
+ on, enable, true Enable auto-fix for mechanical issues
611
+ off, disable, false Disable auto-fix
612
+ status Show current status (default)
577
613
 
578
614
  ⚠ Auto-fix is alpha. When enabled, PRs submitted via \`haystack submit\`
579
615
  are labeled haystack:auto-fix. The Haystack analysis pipeline then
@@ -882,6 +918,38 @@ rules
882
918
  process.exit(1);
883
919
  }
884
920
  });
921
+ // ─── system-map ──────────────────────────────────────────────────────────────
922
+ const systemMap = program
923
+ .command('system-map')
924
+ .description('Manage .haystack/system.yml — runtime facts that help Haystack QA run your system');
925
+ systemMap
926
+ .command('validate')
927
+ .description('Check .haystack/system.yml against the rules Haystack QA will apply')
928
+ .option('--json', 'Machine-readable result')
929
+ .addHelpText('after', `
930
+ The system map holds VERIFIED facts about how your system works — install/
931
+ build commands, how services boot and signal readiness, seed/reset commands,
932
+ dev-only test logins, external services that must never be touched live, and
933
+ prebuilt CI artifacts. Haystack QA uses these facts to run more of what it
934
+ already wants to test; the file never selects tests.
935
+
936
+ Author it with the /map-your-system skill (haystack skills install), then run
937
+ this before committing. Checks: valid YAML + version, no test-selection keys,
938
+ no secret values, non-empty boot/login/run commands, artifact declarations.
939
+
940
+ Examples:
941
+ haystack system-map validate
942
+ haystack system-map validate --json
943
+ `)
944
+ .action(async (opts) => {
945
+ try {
946
+ await systemMapValidateCommand(opts);
947
+ }
948
+ catch (err) {
949
+ console.error(chalk.red('validate failed:'), err instanceof Error ? err.message : err);
950
+ process.exit(1);
951
+ }
952
+ });
885
953
  // ─── mcp ─────────────────────────────────────────────────────────────────────
886
954
  program
887
955
  .command('mcp')
package/dist/schema.d.ts CHANGED
@@ -13,9 +13,13 @@ export declare const SCHEMA_VERSIONS: {
13
13
  readonly triage: "2.0.0";
14
14
  readonly setup: "1.0.0";
15
15
  readonly pr: "2.0.0";
16
+ readonly 'pr-status': "1.0.0";
16
17
  readonly inbox: "1.0.0";
17
18
  readonly ask: "1.0.0";
18
19
  readonly traces: "1.0.0";
20
+ readonly submit: "1.0.0";
21
+ readonly action: "1.0.0";
22
+ readonly error: "1.0.0";
19
23
  };
20
24
  export type SchemaName = keyof typeof SCHEMA_VERSIONS;
21
25
  /** Wrap a payload with the `schema_version` envelope. */
package/dist/schema.js CHANGED
@@ -13,9 +13,13 @@ export const SCHEMA_VERSIONS = {
13
13
  triage: '2.0.0',
14
14
  setup: '1.0.0',
15
15
  pr: '2.0.0',
16
+ 'pr-status': '1.0.0',
16
17
  inbox: '1.0.0',
17
18
  ask: '1.0.0',
18
19
  traces: '1.0.0',
20
+ submit: '1.0.0',
21
+ action: '1.0.0',
22
+ error: '1.0.0',
19
23
  };
20
24
  /** Wrap a payload with the `schema_version` envelope. */
21
25
  export function withSchema(name, payload) {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Canonical machine-readable PR-state vocabulary for every CLI JSON surface.
3
+ *
4
+ * One concept, one spelling: all `--json` output uses snake_case state tokens.
5
+ * Before this module existed the same state appeared as `good_to_merge`
6
+ * (triage/pr get/inbox) and `good-to-merge` (pr-status, raw server buckets),
7
+ * so no single parser worked across commands. Server responses that use
8
+ * kebab-case are normalized at the edge via `normalizeFeedBucket`.
9
+ */
10
+ /** Public triage verdict — emitted by `triage`, `pr get`, `submit`, `pr-status`. */
11
+ export declare const TRIAGE_VERDICTS: readonly ["good_to_merge", "needs_review", "needs_input"];
12
+ export type TriageVerdict = (typeof TRIAGE_VERDICTS)[number];
13
+ /**
14
+ * Map the server's internal analysis verdict ('clean' | 'has-issues' |
15
+ * 'needs-review', with a rating-based fallback for pre-verdict analyses) to
16
+ * the public triage verdict. This mapping used to be duplicated inline in
17
+ * triage.ts and pr.ts.
18
+ */
19
+ export declare function toPublicVerdict(analysisVerdict: string | null | undefined, haystackRating?: number | null): TriageVerdict;
20
+ /**
21
+ * Known feed buckets in canonical snake_case form. The server may introduce
22
+ * new buckets; `normalizeFeedBucket` converts mechanically so unknown buckets
23
+ * pass through in the same convention instead of crashing or leaking kebab-case.
24
+ */
25
+ export declare const FEED_BUCKETS: readonly ["analyzing", "auto_fixing", "good_to_merge", "failed_when_merging", "issues", "needs_assignment", "needs_shepherding", "needs_fixing", "awaiting_review", "review_requested", "snoozed"];
26
+ export type FeedBucket = (typeof FEED_BUCKETS)[number];
27
+ /** Normalize a server feed bucket (kebab-case) to the canonical snake_case form. */
28
+ export declare function normalizeFeedBucket(bucket: string): string;
29
+ export declare function normalizeFeedBucket(bucket: string | null | undefined): string | null;
package/dist/states.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Canonical machine-readable PR-state vocabulary for every CLI JSON surface.
3
+ *
4
+ * One concept, one spelling: all `--json` output uses snake_case state tokens.
5
+ * Before this module existed the same state appeared as `good_to_merge`
6
+ * (triage/pr get/inbox) and `good-to-merge` (pr-status, raw server buckets),
7
+ * so no single parser worked across commands. Server responses that use
8
+ * kebab-case are normalized at the edge via `normalizeFeedBucket`.
9
+ */
10
+ /** Public triage verdict — emitted by `triage`, `pr get`, `submit`, `pr-status`. */
11
+ export const TRIAGE_VERDICTS = ['good_to_merge', 'needs_review', 'needs_input'];
12
+ /**
13
+ * Map the server's internal analysis verdict ('clean' | 'has-issues' |
14
+ * 'needs-review', with a rating-based fallback for pre-verdict analyses) to
15
+ * the public triage verdict. This mapping used to be duplicated inline in
16
+ * triage.ts and pr.ts.
17
+ */
18
+ export function toPublicVerdict(analysisVerdict, haystackRating) {
19
+ const verdict = analysisVerdict ?? ((haystackRating ?? 0) >= 5 ? 'clean' : 'has-issues');
20
+ if (verdict === 'clean')
21
+ return 'good_to_merge';
22
+ if (verdict === 'needs-review')
23
+ return 'needs_review';
24
+ return 'needs_input';
25
+ }
26
+ /**
27
+ * Known feed buckets in canonical snake_case form. The server may introduce
28
+ * new buckets; `normalizeFeedBucket` converts mechanically so unknown buckets
29
+ * pass through in the same convention instead of crashing or leaking kebab-case.
30
+ */
31
+ export const FEED_BUCKETS = [
32
+ 'analyzing',
33
+ 'auto_fixing',
34
+ 'good_to_merge',
35
+ 'failed_when_merging',
36
+ 'issues',
37
+ 'needs_assignment',
38
+ 'needs_shepherding',
39
+ 'needs_fixing',
40
+ 'awaiting_review',
41
+ 'review_requested',
42
+ 'snoozed',
43
+ ];
44
+ export function normalizeFeedBucket(bucket) {
45
+ return bucket == null ? null : bucket.replace(/-/g, '_');
46
+ }
@@ -16,6 +16,13 @@ export interface RunTriageOptions {
16
16
  * Returns the first one found, or null if none available.
17
17
  */
18
18
  export declare function detectAgenticCLI(): AgenticCLI | null;
19
+ /**
20
+ * Build the CLI command + args for the detected agentic tool.
21
+ */
22
+ export declare function buildCommand(cli: AgenticCLI, prompt: string, maxTurns: number): {
23
+ command: string;
24
+ args: string[];
25
+ };
19
26
  /**
20
27
  * Run pre-PR triage checks using parallel sub-agent processes.
21
28
  *
@@ -86,7 +86,7 @@ export function detectAgenticCLI() {
86
86
  /**
87
87
  * Build the CLI command + args for the detected agentic tool.
88
88
  */
89
- function buildCommand(cli, prompt, maxTurns) {
89
+ export function buildCommand(cli, prompt, maxTurns) {
90
90
  switch (cli) {
91
91
  case 'claude':
92
92
  return {
@@ -101,7 +101,7 @@ function buildCommand(cli, prompt, maxTurns) {
101
101
  case 'codex':
102
102
  return {
103
103
  command: 'codex',
104
- args: ['exec', prompt, '--full-auto', '--ephemeral'],
104
+ args: ['exec', prompt, '--full-auto'],
105
105
  };
106
106
  case 'gemini':
107
107
  return {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared machine-readable envelope for the mutating commands
3
+ * (dismiss, mark-reviewed, undismiss, request-review, review).
4
+ *
5
+ * One schema for all of them (`haystack schema action`): every payload
6
+ * carries `action`, `status`, and `ref`, so an agent can drive any state
7
+ * change with a single parser. Success payloads go to stdout as the only
8
+ * stdout content; human/progress output stays on stderr in --json mode.
9
+ */
10
+ export type ActionName = 'dismiss' | 'mark_reviewed' | 'undismiss' | 'request_review' | 'trigger_review' | 'clear_pending';
11
+ export interface ActionPayload extends Record<string, unknown> {
12
+ action: ActionName;
13
+ status: 'ok';
14
+ ref: string;
15
+ /** Human-readable statement of what changed. */
16
+ detail: string;
17
+ }
18
+ /** Print the success envelope (JSON mode only — human output is the caller's). */
19
+ export declare function emitActionPayload(payload: ActionPayload): void;
20
+ /**
21
+ * Print a failure (JSON error envelope on stdout when --json, red text on
22
+ * stderr always) and exit 1.
23
+ */
24
+ export declare function failAction(json: boolean | undefined, action: ActionName, ref: string | null, message: string): never;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared machine-readable envelope for the mutating commands
3
+ * (dismiss, mark-reviewed, undismiss, request-review, review).
4
+ *
5
+ * One schema for all of them (`haystack schema action`): every payload
6
+ * carries `action`, `status`, and `ref`, so an agent can drive any state
7
+ * change with a single parser. Success payloads go to stdout as the only
8
+ * stdout content; human/progress output stays on stderr in --json mode.
9
+ */
10
+ import chalk from 'chalk';
11
+ import { withSchema } from '../schema.js';
12
+ /** Print the success envelope (JSON mode only — human output is the caller's). */
13
+ export function emitActionPayload(payload) {
14
+ console.log(JSON.stringify(withSchema('action', payload), null, 2));
15
+ }
16
+ /**
17
+ * Print a failure (JSON error envelope on stdout when --json, red text on
18
+ * stderr always) and exit 1.
19
+ */
20
+ export function failAction(json, action, ref, message) {
21
+ if (json) {
22
+ process.stdout.write(JSON.stringify(withSchema('action', { action, status: 'error', ref, error: message }), null, 2) + '\n');
23
+ }
24
+ console.error(chalk.red(`\n${message}\n`));
25
+ process.exit(1);
26
+ }
@@ -172,5 +172,16 @@ export interface AutoFixManifest {
172
172
  timestamp: string;
173
173
  resultSha?: string;
174
174
  }
175
+ /**
176
+ * Null means "artifact does not exist (yet)" — 404s and transient
177
+ * network/parse failures. Auth failures (401/403) THROW a classified
178
+ * HaystackApiError instead: collapsing them into null used to make an
179
+ * expired token read as "not available yet — try again in a moment",
180
+ * sending agents into a retry loop when the fix was `haystack login`.
181
+ */
175
182
  export declare function fetchAutoFixManifest(owner: string, repo: string, prNumber: number, token?: string): Promise<AutoFixManifest | null>;
183
+ /**
184
+ * Null means "synthesis does not exist (yet)". Auth failures (401/403) THROW
185
+ * a classified HaystackApiError — see fetchAutoFixManifest for why.
186
+ */
176
187
  export declare function fetchRatingSynthesis(owner: string, repo: string, prNumber: number, token?: string): Promise<RatingSynthesis | null>;
@@ -7,6 +7,7 @@
7
7
  * Analysis is triggered automatically by the GitHub App webhook when a PR
8
8
  * is created or updated — the CLI does not need to trigger it directly.
9
9
  */
10
+ import { classifyHttpError } from './haystack-api.js';
10
11
  // ============================================================================
11
12
  // Constants
12
13
  // ============================================================================
@@ -187,7 +188,9 @@ export async function checkAnalysisReady(owner, repo, prNumber, token) {
187
188
  if (response.status === 404)
188
189
  return { status: 'pending' };
189
190
  if (!response.ok) {
190
- return { status: 'error', message: `HTTP ${response.status}` };
191
+ // Classified, actionable message — a bare "HTTP 403" used to send agents
192
+ // into a retry loop when the fix was `haystack login`.
193
+ return { status: 'error', message: (await classifyHttpError(response, 'Analysis status check')).message };
191
194
  }
192
195
  let data;
193
196
  try {
@@ -336,12 +339,22 @@ export async function fetchAnalysisResults(owner, repo, prNumber, token) {
336
339
  needsHumanReview,
337
340
  };
338
341
  }
342
+ /**
343
+ * Null means "artifact does not exist (yet)" — 404s and transient
344
+ * network/parse failures. Auth failures (401/403) THROW a classified
345
+ * HaystackApiError instead: collapsing them into null used to make an
346
+ * expired token read as "not available yet — try again in a moment",
347
+ * sending agents into a retry loop when the fix was `haystack login`.
348
+ */
339
349
  export async function fetchAutoFixManifest(owner, repo, prNumber, token) {
340
350
  const url = v3ResultUrl(owner, repo, prNumber, 'auto-fix-manifest.json');
341
351
  try {
342
352
  const response = await fetch(url, {
343
353
  headers: apiHeaders(token),
344
354
  });
355
+ if (response.status === 401 || response.status === 403) {
356
+ throw await classifyHttpError(response, 'Auto-fix manifest fetch');
357
+ }
345
358
  if (!response.ok)
346
359
  return null;
347
360
  const data = await response.json();
@@ -355,10 +368,17 @@ export async function fetchAutoFixManifest(owner, repo, prNumber, token) {
355
368
  }
356
369
  return data;
357
370
  }
358
- catch {
371
+ catch (err) {
372
+ if (err instanceof Error && err.name === 'HaystackApiError')
373
+ throw err;
374
+ // Transient network/parse failure — same "not available" semantics as 404.
359
375
  return null;
360
376
  }
361
377
  }
378
+ /**
379
+ * Null means "synthesis does not exist (yet)". Auth failures (401/403) THROW
380
+ * a classified HaystackApiError — see fetchAutoFixManifest for why.
381
+ */
362
382
  export async function fetchRatingSynthesis(owner, repo, prNumber, token) {
363
383
  // Route through the auth-worker (/api/analysis/{prId}/rating_synthesis.json)
364
384
  // so hsk_live tokens are introspected and the scope gate applies — see
@@ -368,6 +388,9 @@ export async function fetchRatingSynthesis(owner, repo, prNumber, token) {
368
388
  const response = await fetch(url, {
369
389
  headers: apiHeaders(token),
370
390
  });
391
+ if (response.status === 401 || response.status === 403) {
392
+ throw await classifyHttpError(response, 'Rating synthesis fetch');
393
+ }
371
394
  if (!response.ok)
372
395
  return null;
373
396
  const data = await response.json();
@@ -384,7 +407,10 @@ export async function fetchRatingSynthesis(owner, repo, prNumber, token) {
384
407
  }
385
408
  return data;
386
409
  }
387
- catch {
410
+ catch (err) {
411
+ if (err instanceof Error && err.name === 'HaystackApiError')
412
+ throw err;
413
+ // Transient network/parse failure — same "not available" semantics as 404.
388
414
  return null;
389
415
  }
390
416
  }
@@ -399,6 +399,13 @@ export async function resolveAuthContext(options = {}) {
399
399
  // — that intent should not be overridden by a stale OAuth cookie.
400
400
  const headless = await readHeadlessToken();
401
401
  if (headless) {
402
+ // The headless token wins, but dropping an explicit --account silently
403
+ // would leave the caller authenticated as someone they didn't ask for —
404
+ // say so on stderr.
405
+ if (options.preferredLogin) {
406
+ console.error(`--account ${options.preferredLogin} is ignored: a headless token (${headless.label ?? 'token file'}) takes precedence. ` +
407
+ 'Unset HAYSTACK_CLI_TOKEN or remove the token file (`haystack logout --headless`) to use saved accounts.');
408
+ }
402
409
  return {
403
410
  login: headless.label ?? 'headless',
404
411
  token: headless.token,
@@ -78,7 +78,10 @@ export declare class UnsupportedRemoteUrlError extends Error {
78
78
  */
79
79
  export declare function parseRemoteUrl(remoteName?: string): GitRemoteInfo;
80
80
  /**
81
- * Get the last commit message (first line only)
81
+ * Get the last commit message (first line only).
82
+ *
83
+ * Throws instead of guessing: this feeds the default PR title, and the old
84
+ * silent fallback ('Update') shipped meaningless titles when git failed.
82
85
  */
83
86
  export declare function getLastCommitMessage(): string;
84
87
  /**
package/dist/utils/git.js CHANGED
@@ -22,8 +22,10 @@ export function getCurrentBranch() {
22
22
  stdio: ['pipe', 'pipe', 'pipe'],
23
23
  }).trim();
24
24
  }
25
- catch {
26
- throw new Error('Failed to get current branch. Are you in a git repository?');
25
+ catch (err) {
26
+ const stderr = err?.stderr?.toString().trim();
27
+ throw new Error(`Failed to get the current branch${stderr ? ` (${stderr})` : ''}. ` +
28
+ 'Run from inside a git repository.');
27
29
  }
28
30
  }
29
31
  /**
@@ -296,19 +298,25 @@ export function parseRemoteUrl(remoteName = 'origin') {
296
298
  }
297
299
  catch (err) {
298
300
  // Propagate the "unrecognized URL" case as-is; wrap everything else (the
299
- // `git remote get-url` command itself failing) in a friendlier message.
301
+ // `git remote get-url` command itself failing) with the underlying git
302
+ // error preserved — "No such remote 'origin'" is the actual diagnosis.
300
303
  // Classify by type, not message text (see pr-rules.yml PR007).
301
304
  if (err instanceof UnsupportedRemoteUrlError) {
302
305
  throw err;
303
306
  }
304
- throw new Error(`Failed to get remote URL for '${remoteName}'`);
307
+ const stderr = err?.stderr?.toString().trim();
308
+ const cause = stderr || (err instanceof Error ? err.message : String(err));
309
+ throw new Error(`Failed to get remote URL for '${remoteName}': ${cause}`);
305
310
  }
306
311
  }
307
312
  // ============================================================================
308
313
  // Commit operations
309
314
  // ============================================================================
310
315
  /**
311
- * Get the last commit message (first line only)
316
+ * Get the last commit message (first line only).
317
+ *
318
+ * Throws instead of guessing: this feeds the default PR title, and the old
319
+ * silent fallback ('Update') shipped meaningless titles when git failed.
312
320
  */
313
321
  export function getLastCommitMessage() {
314
322
  try {
@@ -317,8 +325,10 @@ export function getLastCommitMessage() {
317
325
  stdio: ['pipe', 'pipe', 'pipe'],
318
326
  }).trim();
319
327
  }
320
- catch {
321
- return 'Update';
328
+ catch (err) {
329
+ const stderr = err?.stderr?.toString().trim();
330
+ throw new Error(`Could not read the last commit message${stderr ? ` (${stderr})` : ''}. ` +
331
+ 'Commit your changes first, or pass an explicit --title.');
322
332
  }
323
333
  }
324
334
  /**
@@ -12,6 +12,21 @@ export interface CliIdentity {
12
12
  login: string;
13
13
  id: number;
14
14
  }
15
+ /**
16
+ * Typed error for non-OK Haystack API responses. Callers classify by
17
+ * `status` (never by message substring) and the message always carries a
18
+ * remediation an agent can act on.
19
+ */
20
+ export declare class HaystackApiError extends Error {
21
+ readonly status: number;
22
+ constructor(status: number, message: string);
23
+ get isAuthError(): boolean;
24
+ }
25
+ /**
26
+ * Build an actionable error for a non-OK response: what failed, the HTTP
27
+ * class it failed with, and the next command to run.
28
+ */
29
+ export declare function classifyHttpError(response: Response, context: string): Promise<HaystackApiError>;
15
30
  export declare function haystackJson<T>(path: string, token: string, init?: RequestInit): Promise<T>;
16
31
  export declare function fetchCliIdentity(token: string): Promise<CliIdentity>;
17
32
  export declare function fetchHaystackInstallations(token: string): Promise<UserInstallation[]>;