@haystackeditor/cli 0.15.19 → 0.15.21

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 (66) hide show
  1. package/README.md +100 -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/commands/verify-core.d.ts +424 -0
  28. package/dist/commands/verify-core.js +753 -0
  29. package/dist/commands/verify-mcp.d.ts +8 -0
  30. package/dist/commands/verify-mcp.js +458 -0
  31. package/dist/commands/verify-ops.d.ts +158 -0
  32. package/dist/commands/verify-ops.js +1148 -0
  33. package/dist/commands/verify-reseal.d.ts +9 -0
  34. package/dist/commands/verify-reseal.js +148 -0
  35. package/dist/commands/verify-sandboxes.d.ts +95 -0
  36. package/dist/commands/verify-sandboxes.js +352 -0
  37. package/dist/commands/verify.d.ts +11 -0
  38. package/dist/commands/verify.js +97 -0
  39. package/dist/index.js +343 -20
  40. package/dist/schema.d.ts +4 -0
  41. package/dist/schema.js +4 -0
  42. package/dist/states.d.ts +29 -0
  43. package/dist/states.js +46 -0
  44. package/dist/triage/runner.d.ts +7 -0
  45. package/dist/triage/runner.js +2 -2
  46. package/dist/types.d.ts +54 -0
  47. package/dist/types.js +15 -0
  48. package/dist/utils/action-output.d.ts +24 -0
  49. package/dist/utils/action-output.js +26 -0
  50. package/dist/utils/analysis-api.d.ts +11 -0
  51. package/dist/utils/analysis-api.js +29 -3
  52. package/dist/utils/auth.js +7 -0
  53. package/dist/utils/git.d.ts +4 -1
  54. package/dist/utils/git.js +17 -7
  55. package/dist/utils/haystack-api.d.ts +15 -0
  56. package/dist/utils/haystack-api.js +58 -7
  57. package/dist/utils/pr-ref.d.ts +27 -0
  58. package/dist/utils/pr-ref.js +56 -0
  59. package/package.json +2 -2
  60. package/schemas/action.v1.json +22 -0
  61. package/schemas/error.v1.json +14 -0
  62. package/schemas/pr-status.v1.json +61 -0
  63. package/schemas/pr.v1.json +80 -17
  64. package/schemas/setup.v1.json +66 -12
  65. package/schemas/submit.v1.json +88 -0
  66. package/schemas/triage.v1.json +71 -18
@@ -19,6 +19,8 @@ import { checkAnalysisReady, fetchAnalysisResults } from '../utils/analysis-api.
19
19
  import { savePendingSubmit, updatePendingSubmit } from '../utils/pending-state.js';
20
20
  import { ensureHaystackLabels, addLabelsToIssue, } from '../utils/github-api.js';
21
21
  import { listStoredAccounts, resolveAuthContext, setRepoDefaultAccount, } from '../utils/auth.js';
22
+ import { HaystackApiError } from '../utils/haystack-api.js';
23
+ import { withSchema } from '../schema.js';
22
24
  // ============================================================================
23
25
  // Helpers
24
26
  // ============================================================================
@@ -248,6 +250,26 @@ function printTriageSummary(result) {
248
250
  // Main command
249
251
  // ============================================================================
250
252
  export async function submitCommand(options) {
253
+ // --json contract: stdout carries exactly one JSON document (the submit
254
+ // payload or an error envelope); every progress/status line goes to stderr.
255
+ // Redirecting console.log wholesale keeps the existing progress output
256
+ // intact for humans watching stderr. (console.error/warn already hit stderr.)
257
+ if (options.json) {
258
+ console.log = (...args) => {
259
+ process.stderr.write(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ') + '\n');
260
+ };
261
+ }
262
+ /** Print a classified failure (JSON envelope on stdout when --json) and exit 1. */
263
+ const fail = (message, extra) => {
264
+ if (options.json) {
265
+ process.stdout.write(JSON.stringify(withSchema('submit', { status: 'error', error: message, ...extra }), null, 2) + '\n');
266
+ console.error(chalk.red(`\n${message}\n`));
267
+ }
268
+ else {
269
+ console.error(chalk.red(`\n${message}\n`));
270
+ }
271
+ process.exit(1);
272
+ };
251
273
  console.log(chalk.bold.cyan('\nHaystack Submit\n'));
252
274
  // -------------------------------------------------------------------------
253
275
  // Step 1: Validate environment
@@ -255,14 +277,13 @@ export async function submitCommand(options) {
255
277
  console.log(chalk.dim('Checking environment...'));
256
278
  // Validate mutually exclusive options
257
279
  if (options.body && options.bodyFile) {
258
- console.error(chalk.red('\nCannot use both --body and --body-file. Pick one.\n'));
259
- process.exit(1);
280
+ fail('Cannot use both --body and --body-file. Pick one.');
260
281
  }
261
282
  // Check git repo
262
283
  const gitRoot = findGitRoot();
263
284
  if (!gitRoot) {
264
- console.error(chalk.red('\nNot a git repository.\n'));
265
- process.exit(1);
285
+ fail('Not a git repository. Run `haystack submit` from inside a repository checkout.');
286
+ return; // unreachable — satisfies TS narrowing
266
287
  }
267
288
  // Get remote info
268
289
  let remoteInfo;
@@ -271,8 +292,8 @@ export async function submitCommand(options) {
271
292
  console.log(chalk.green(`✓ Repository: ${remoteInfo.owner}/${remoteInfo.repo}`));
272
293
  }
273
294
  catch (err) {
274
- console.error(chalk.red(`\n${err.message}\n`));
275
- process.exit(1);
295
+ fail(err.message);
296
+ return; // unreachable — satisfies TS narrowing
276
297
  }
277
298
  let githubToken;
278
299
  let activeGitHubLogin;
@@ -294,8 +315,7 @@ export async function submitCommand(options) {
294
315
  }
295
316
  }
296
317
  catch (err) {
297
- console.error(chalk.red(`\n${err.message}\n`));
298
- process.exit(1);
318
+ fail(err.message);
299
319
  }
300
320
  // Check for uncommitted changes
301
321
  if (hasUncommittedChanges()) {
@@ -305,10 +325,14 @@ export async function submitCommand(options) {
305
325
  const currentBranch = getCurrentBranch();
306
326
  const baseBranch = options.base || getDefaultBranch();
307
327
  console.log(chalk.dim(` Current branch: ${currentBranch}`));
308
- console.log(chalk.dim(` Base branch: ${baseBranch}`));
328
+ console.log(chalk.dim(` Base branch: ${baseBranch}${options.base ? '' : ' (detected from origin)'}`));
309
329
  // -------------------------------------------------------------------------
310
330
  // Step 2: Pre-PR Triage
311
331
  // -------------------------------------------------------------------------
332
+ // Recorded for the --json payload: what triage concluded before the PR
333
+ // was created. 'unavailable' = infrastructure gave zero signal (never
334
+ // reported as 'passed').
335
+ let triageStatus = 'skipped';
312
336
  if (!options.force) {
313
337
  console.log(chalk.dim('\nRunning pre-PR triage checks...'));
314
338
  try {
@@ -330,10 +354,22 @@ export async function submitCommand(options) {
330
354
  // We already warned above and continue.
331
355
  }
332
356
  else {
333
- console.log(chalk.red('\nTriage failed. Fix the issues above or re-run with --force.\n'));
334
- process.exit(1);
357
+ fail('Triage failed. Fix the issues above or re-run with --force.', {
358
+ triage: {
359
+ status: 'failed',
360
+ issues: triageResult.results.flatMap((r) => r.issues.map((issue) => ({
361
+ checker: r.checker,
362
+ severity: issue.severity,
363
+ file: issue.file,
364
+ line: issue.line ?? null,
365
+ message: issue.message,
366
+ rule: issue.rule ?? null,
367
+ }))),
368
+ },
369
+ });
335
370
  }
336
371
  }
372
+ triageStatus = triageInfraFailure ? 'unavailable' : 'passed';
337
373
  }
338
374
  catch (err) {
339
375
  // Triage failure should not block submission
@@ -346,6 +382,7 @@ export async function submitCommand(options) {
346
382
  else {
347
383
  console.log(chalk.yellow(`\n⚠ Triage skipped: ${err.message}`));
348
384
  }
385
+ triageStatus = 'unavailable';
349
386
  }
350
387
  }
351
388
  else {
@@ -355,24 +392,22 @@ export async function submitCommand(options) {
355
392
  // Step 3: Check if on main/master
356
393
  // -------------------------------------------------------------------------
357
394
  if (currentBranch === 'main' || currentBranch === 'master') {
358
- console.error(chalk.red(`\nRefusing to submit from ${currentBranch}. Create a feature branch first.\n`));
359
- process.exit(1);
395
+ fail(`Refusing to submit from ${currentBranch}. Create a feature branch first (e.g. \`git checkout -b my-change\`), then re-run \`haystack submit\`.`);
360
396
  }
361
397
  // -------------------------------------------------------------------------
362
398
  // Step 4: Push to remote
363
399
  // -------------------------------------------------------------------------
364
400
  console.log(chalk.dim('\nPushing to origin...'));
365
401
  if (!githubToken) {
366
- console.error(chalk.red('\nInternal error: no Haystack account token resolved before push.\n'));
367
- process.exit(1);
402
+ fail('No Haystack account token was resolved before push (internal error). Run `haystack login`, then retry.');
403
+ return; // unreachable — satisfies TS narrowing
368
404
  }
369
405
  try {
370
406
  pushBranch('origin', currentBranch, { token: githubToken });
371
407
  console.log(chalk.green('✓ Branch pushed'));
372
408
  }
373
409
  catch (err) {
374
- console.error(chalk.red(`\n${err.message}\n`));
375
- process.exit(1);
410
+ fail(err.message);
376
411
  }
377
412
  // -------------------------------------------------------------------------
378
413
  // Step 5: Create or find PR
@@ -382,30 +417,53 @@ export async function submitCommand(options) {
382
417
  const existingPR = await findExistingPR(remoteInfo.owner, remoteInfo.repo, currentBranch, githubToken);
383
418
  let prNumber;
384
419
  let prUrl;
420
+ let prTitle;
421
+ // Where the posted title/body came from — echoed to the user and recorded
422
+ // in the --json payload so the derived values are never silent.
423
+ let titleSource;
424
+ let bodySource;
425
+ let prBody = null;
385
426
  if (existingPR) {
386
427
  console.log(chalk.yellow(`PR already exists: #${existingPR.number}`));
387
428
  prNumber = existingPR.number;
388
429
  prUrl = existingPR.html_url;
430
+ prTitle = existingPR.title;
431
+ titleSource = 'existing-pr';
432
+ bodySource = 'existing-pr';
389
433
  }
390
434
  else {
391
435
  // Generate PR title and body
392
- const prTitle = options.title || getLastCommitMessage();
393
- let bodyContent;
436
+ try {
437
+ prTitle = options.title || getLastCommitMessage();
438
+ }
439
+ catch (err) {
440
+ fail(err.message);
441
+ return; // unreachable — satisfies TS narrowing
442
+ }
443
+ titleSource = options.title ? 'flag' : 'last-commit';
444
+ let bodyContent = '';
394
445
  if (options.bodyFile) {
446
+ bodySource = options.bodyFile === '-' ? 'stdin' : 'body-file';
395
447
  try {
396
448
  bodyContent = options.bodyFile === '-'
397
449
  ? fs.readFileSync(0, 'utf-8')
398
450
  : fs.readFileSync(options.bodyFile, 'utf-8');
399
451
  }
400
452
  catch (err) {
401
- console.error(chalk.red(`\nFailed to read body file: ${err.message}\n`));
402
- process.exit(1);
453
+ fail(`Failed to read body file: ${err.message}`);
403
454
  }
404
455
  }
456
+ else if (options.body) {
457
+ bodySource = 'flag';
458
+ bodyContent = options.body;
459
+ }
405
460
  else {
406
- bodyContent = options.body || getCommitMessagesSinceBase(baseBranch);
461
+ bodySource = 'commit-messages';
462
+ bodyContent = getCommitMessagesSinceBase(baseBranch);
407
463
  }
408
- const prBody = buildPRBody(bodyContent);
464
+ prBody = buildPRBody(bodyContent);
465
+ console.log(chalk.dim(` Title: ${prTitle} (from ${titleSource === 'flag' ? '--title' : 'last commit message'})`));
466
+ console.log(chalk.dim(` Body: from ${bodySource === 'commit-messages' ? 'commit messages since base' : bodySource}`));
409
467
  try {
410
468
  const pr = await createPullRequest({
411
469
  owner: remoteInfo.owner,
@@ -422,32 +480,46 @@ export async function submitCommand(options) {
422
480
  console.log(chalk.green(`✓ Pull request created: #${prNumber}`));
423
481
  }
424
482
  catch (err) {
425
- console.error(chalk.red(`\n${formatSubmitPermissionError(err, activeGitHubLogin, remoteInfo.owner, remoteInfo.repo)}\n`));
426
- process.exit(1);
483
+ fail(formatSubmitPermissionError(err, activeGitHubLogin, remoteInfo.owner, remoteInfo.repo));
484
+ return; // unreachable — satisfies TS narrowing
427
485
  }
428
486
  }
429
487
  // -------------------------------------------------------------------------
430
488
  // Step 6: Apply auto-merge label if enabled
431
489
  // -------------------------------------------------------------------------
490
+ // Applied-state tracking: the --json payload must report what actually
491
+ // happened on GitHub, not what was requested — label application can fail
492
+ // (403/422) and only warn.
493
+ let autoMergeApplied = false;
494
+ let autoFixApplied = false;
495
+ let reviewLabelApplied = false;
496
+ let reviewerRequestedOk = null;
497
+ // Announce the resolved value AND its source in both directions — a silent
498
+ // config-driven default is invisible to the caller otherwise.
499
+ const autoMergeSourceLabel = options.autoMergeSource === 'flag' ? '--auto-merge flag' : '.haystack.json';
432
500
  if (options.autoMerge) {
433
501
  try {
434
502
  await ensureHaystackLabels(remoteInfo.owner, remoteInfo.repo, ['haystack:auto-merge'], githubToken);
435
503
  await addLabelsToIssue(remoteInfo.owner, remoteInfo.repo, prNumber, ['haystack:auto-merge'], githubToken);
504
+ autoMergeApplied = true;
436
505
  // With --review, the PR still enters the auto-merge queue but the
437
506
  // merge queue's needs-review gate holds it until a human approves
438
507
  // (removes haystack:needs-review or marks it reviewed). Don't print a
439
508
  // bare "Auto-merge enabled" that reads as if it'll merge unreviewed.
440
509
  if (options.review) {
441
- console.log(chalk.green('✓ Auto-merge enabled (held until a human approves this PR)'));
510
+ console.log(chalk.green(`✓ Auto-merge enabled via ${autoMergeSourceLabel} (held until a human approves this PR)`));
442
511
  }
443
512
  else {
444
- console.log(chalk.green('✓ Auto-merge enabled for this PR'));
513
+ console.log(chalk.green(`✓ Auto-merge enabled for this PR (via ${autoMergeSourceLabel})`));
445
514
  }
446
515
  }
447
516
  catch (err) {
448
517
  console.log(chalk.yellow(`⚠ Could not set auto-merge label: ${err.message}`));
449
518
  }
450
519
  }
520
+ else {
521
+ console.log(chalk.dim(` Auto-merge: disabled (via ${autoMergeSourceLabel})`));
522
+ }
451
523
  // -------------------------------------------------------------------------
452
524
  // Step 6b: Apply auto-fix label if --auto-fix
453
525
  // -------------------------------------------------------------------------
@@ -455,7 +527,8 @@ export async function submitCommand(options) {
455
527
  try {
456
528
  await ensureHaystackLabels(remoteInfo.owner, remoteInfo.repo, ['haystack:auto-fix'], githubToken);
457
529
  await addLabelsToIssue(remoteInfo.owner, remoteInfo.repo, prNumber, ['haystack:auto-fix'], githubToken);
458
- console.log(chalk.green('✓ Auto-fix (alpha) enabled for this PR'));
530
+ autoFixApplied = true;
531
+ console.log(chalk.green(`✓ Auto-fix (alpha) enabled for this PR (via ${options.autoFixSource === 'flag' ? '--auto-fix flag' : '.haystack.json'})`));
459
532
  }
460
533
  catch (err) {
461
534
  console.log(chalk.yellow(`⚠ Could not set auto-fix label: ${err.message}`));
@@ -468,9 +541,11 @@ export async function submitCommand(options) {
468
541
  try {
469
542
  await ensureHaystackLabels(remoteInfo.owner, remoteInfo.repo, ['haystack:needs-review'], githubToken);
470
543
  await addLabelsToIssue(remoteInfo.owner, remoteInfo.repo, prNumber, ['haystack:needs-review'], githubToken);
544
+ reviewLabelApplied = true;
471
545
  if (typeof options.review === 'string') {
472
546
  // Specific reviewer requested
473
547
  await requestReviewers(remoteInfo.owner, remoteInfo.repo, prNumber, [options.review], githubToken);
548
+ reviewerRequestedOk = options.review;
474
549
  console.log(chalk.green(`✓ Review requested from ${options.review}`));
475
550
  }
476
551
  else {
@@ -486,9 +561,9 @@ export async function submitCommand(options) {
486
561
  // Step 8: Success!
487
562
  // -------------------------------------------------------------------------
488
563
  console.log(chalk.green('\n' + '━'.repeat(50)));
489
- console.log(chalk.bold.green('\nPull Request Created!\n'));
564
+ console.log(chalk.bold.green(existingPR ? '\nPull Request Updated!\n' : '\nPull Request Created!\n'));
490
565
  console.log(` ${chalk.dim('URL:')} ${chalk.cyan(prUrl)}`);
491
- console.log(` ${chalk.dim('Title:')} ${options.title || getLastCommitMessage()}`);
566
+ console.log(` ${chalk.dim('Title:')} ${prTitle}`);
492
567
  console.log(` ${chalk.dim('Branch:')} ${currentBranch} → ${baseBranch}`);
493
568
  if (options.autoMerge) {
494
569
  // --review and auto-merge are not contradictory: the PR is queued but the
@@ -526,10 +601,42 @@ export async function submitCommand(options) {
526
601
  status: 'polling',
527
602
  };
528
603
  savePendingSubmit(pendingState, gitRoot);
604
+ const ref = `${remoteInfo.owner}/${remoteInfo.repo}#${prNumber}`;
605
+ /** Emit the final --json payload (stdout) — the single machine-readable
606
+ * document of the run. `next` always tells the agent the follow-up command. */
607
+ const emitPayload = (analysis) => {
608
+ if (!options.json)
609
+ return;
610
+ process.stdout.write(JSON.stringify(withSchema('submit', {
611
+ status: existingPR ? 'already_exists' : 'created',
612
+ ref,
613
+ pr_number: prNumber,
614
+ url: prUrl,
615
+ title: prTitle,
616
+ title_source: titleSource,
617
+ body: prBody,
618
+ body_source: bodySource,
619
+ branch: currentBranch,
620
+ base: baseBranch,
621
+ draft: options.draft ?? false,
622
+ account: activeGitHubLogin ?? null,
623
+ // Applied state, not requested state: if a label failed to apply, the
624
+ // payload says so (enabled: false) even though it was asked for.
625
+ auto_merge: { enabled: autoMergeApplied, source: options.autoMergeSource ?? 'config' },
626
+ auto_fix: { enabled: autoFixApplied, source: options.autoFixSource ?? 'config' },
627
+ review_requested: reviewLabelApplied
628
+ ? { reviewer: reviewerRequestedOk }
629
+ : null,
630
+ triage: { status: triageStatus },
631
+ analysis,
632
+ next: analysis.status === 'pending' ? `haystack triage ${ref} --json` : null,
633
+ }), null, 2) + '\n');
634
+ };
529
635
  // If --no-wait or non-interactive, exit here
530
636
  if (options.wait === false || !process.stdin.isTTY) {
531
- console.log(chalk.dim('\nAnalysis running in background.'));
532
- console.log(chalk.dim('Check results later with: ') + chalk.cyan('haystack triage\n'));
637
+ console.log(chalk.dim(`\nAnalysis running in background${options.wait === false ? '' : ' (no TTY — not waiting inline)'}.`));
638
+ console.log(chalk.dim('Check results later with: ') + chalk.cyan(`haystack triage ${ref}\n`));
639
+ emitPayload({ status: 'pending', verdict: null, merged: false, summary: null });
533
640
  return;
534
641
  }
535
642
  // Wait for analysis inline by polling the result endpoint
@@ -539,7 +646,8 @@ export async function submitCommand(options) {
539
646
  const sigintHandler = () => {
540
647
  interrupted = true;
541
648
  console.log(chalk.dim('\n\nAnalysis still running. Check later with:'));
542
- console.log(chalk.cyan(' haystack triage\n'));
649
+ console.log(chalk.cyan(` haystack triage ${ref}\n`));
650
+ emitPayload({ status: 'pending', verdict: null, merged: false, summary: null });
543
651
  process.exit(0);
544
652
  };
545
653
  process.on('SIGINT', sigintHandler);
@@ -548,6 +656,7 @@ export async function submitCommand(options) {
548
656
  const deadline = Date.now() + POLL_TIMEOUT;
549
657
  let consecutiveErrors = 0;
550
658
  const MAX_CONSECUTIVE_ERRORS = 5;
659
+ let emitted = false;
551
660
  while (Date.now() < deadline && !interrupted) {
552
661
  const result = await checkAnalysisReady(remoteInfo.owner, remoteInfo.repo, prNumber, githubToken);
553
662
  if (result.status === 'ready') {
@@ -608,10 +717,33 @@ export async function submitCommand(options) {
608
717
  if (!options.autoFix || verdict.state === 'good-to-merge') {
609
718
  console.log(`\n ${chalk.dim('Review:')} ${chalk.cyan(verdict.reviewUrl)}\n`);
610
719
  }
720
+ emitPayload({
721
+ status: 'complete',
722
+ // Same public vocabulary as `triage --json` / `pr get --json`.
723
+ verdict: verdict.state === 'good-to-merge'
724
+ ? 'good_to_merge'
725
+ : verdict.needsHumanReview ? 'needs_review' : 'needs_input',
726
+ merged: verdict.autoMerged ?? false,
727
+ summary: verdict.summary,
728
+ });
729
+ emitted = true;
611
730
  }
612
- catch {
613
- console.log(chalk.yellow('⚠ Analysis complete but could not fetch results.'));
614
- console.log(chalk.dim(' Check results at: ') + chalk.cyan(prUrl) + '\n');
731
+ catch (err) {
732
+ const message = err.message;
733
+ if (err instanceof HaystackApiError && err.isAuthError) {
734
+ // Auth failure is not transient — retrying `haystack triage` with the
735
+ // same credential would fail identically. Report failed + exit 1.
736
+ console.error(chalk.red(`\n ${message}`));
737
+ emitPayload({ status: 'failed', verdict: null, merged: false, summary: message });
738
+ process.exitCode = 1;
739
+ }
740
+ else {
741
+ // The PR exists and analysis completed — only the result fetch failed.
742
+ console.log(chalk.yellow(`⚠ Analysis complete but could not fetch results: ${message}`));
743
+ console.log(chalk.dim(' Check results with: ') + chalk.cyan(`haystack triage ${ref}\n`));
744
+ emitPayload({ status: 'pending', verdict: null, merged: false, summary: message });
745
+ }
746
+ emitted = true;
615
747
  }
616
748
  break;
617
749
  }
@@ -620,19 +752,27 @@ export async function submitCommand(options) {
620
752
  if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
621
753
  console.log(chalk.red(`\n Analysis check failed: ${result.message}`));
622
754
  updatePendingSubmit({ status: 'failed', resolvedAt: new Date().toISOString() }, gitRoot);
755
+ emitPayload({ status: 'failed', verdict: null, merged: false, summary: result.message ?? null });
756
+ emitted = true;
757
+ // The PR was created, but the caller asked us to wait for a verdict
758
+ // and we could not deliver one — that is a failure, not a success.
759
+ process.exitCode = 1;
623
760
  break;
624
761
  }
625
762
  }
626
763
  else {
627
764
  consecutiveErrors = 0;
628
765
  }
629
- process.stdout.write(`\r ${chalk.dim('Status:')} Waiting for analysis...${' '.repeat(10)}`);
766
+ process.stderr.write(`\r ${chalk.dim('Status:')} Waiting for analysis...${' '.repeat(10)}`);
630
767
  await new Promise(r => setTimeout(r, POLL_INTERVAL));
631
768
  }
632
769
  // Clean up SIGINT handler
633
770
  process.removeListener('SIGINT', sigintHandler);
634
771
  if (Date.now() >= deadline && !interrupted) {
635
772
  console.log(chalk.yellow('\n\n Timed out waiting for analysis.'));
636
- console.log(chalk.dim(' Check results later with: ') + chalk.cyan('haystack triage\n'));
773
+ console.log(chalk.dim(' Check results later with: ') + chalk.cyan(`haystack triage ${ref}\n`));
774
+ if (!emitted) {
775
+ emitPayload({ status: 'pending', verdict: null, merged: false, summary: 'Timed out waiting for analysis; it continues server-side.' });
776
+ }
637
777
  }
638
778
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `haystack system-map validate` — instant feedback on `.haystack/system.yml`.
3
+ *
4
+ * The system map holds customer-declared facts about how their system WORKS
5
+ * (build, boot, seed, log in, observe) so Haystack QA can run it. It never
6
+ * selects tests — Haystack decides what to test from each PR's diff. The
7
+ * `/map-your-system` skill authors the file; this command lets a user (or
8
+ * their coding agent) check it before committing, instead of waiting for the
9
+ * next QA run to reject it.
10
+ *
11
+ * Source-of-truth note: the QA pipeline's own loader for this file
12
+ * (`analysis/src/agents/systemMap.ts`) lives on the incoming QA-replay
13
+ * subsystem branch and is NOT on main yet — until it lands, this command is
14
+ * the only validator of the format, and its rules were copied verbatim from
15
+ * that loader. Once the subsystem merges, the analysis-side loader becomes
16
+ * the source of truth (it is a separate deployable the CLI cannot import),
17
+ * and rule changes must update both in the same PR.
18
+ */
19
+ export interface SystemMapIssue {
20
+ severity: 'error' | 'warning';
21
+ path: string;
22
+ message: string;
23
+ }
24
+ export interface SystemMapSummary {
25
+ setup_steps: number;
26
+ toolchain_entries: number;
27
+ services: string[];
28
+ personas: string[];
29
+ data_commands: number;
30
+ observability_entries: number;
31
+ external_services: string[];
32
+ testing_entries: number;
33
+ constraints: number;
34
+ artifacts: string[];
35
+ }
36
+ export declare function validateSystemMapText(rawYaml: string): {
37
+ issues: SystemMapIssue[];
38
+ summary: SystemMapSummary | null;
39
+ };
40
+ export declare function systemMapValidateCommand(options: {
41
+ json?: boolean;
42
+ }): Promise<void>;