@haystackeditor/cli 0.30.2 → 0.31.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.
@@ -74,12 +74,10 @@ export function brokenCopy(view) {
74
74
  }
75
75
  /** What `haystack verify` says first of a broken copy. */
76
76
  export const BROKEN_COPY_HEADLINE = "We couldn't test your change: the app copy is broken.";
77
- /** What the coding agent does about a broken copy: the app's own setup says what the errors need; until a broken copy updates its
78
- * setup (onboarding amendment 38 updates it only from a check that could not start the app), that is sent to the Haystack team
79
- * (`haystack feedback`), and the user is told the change was not tested. */
77
+ /** What the coding agent does about a broken copy: explain the missing setup in run notes, then ask for the check again. */
80
78
  export const BROKEN_COPY_STEPS = [
81
79
  "Find what these errors point to in the repository's own setup (docker compose files, env examples, docs): the services, settings and data the app needs to run.",
82
- 'Send what you found with `haystack feedback "<what the app needs to run>"`: a copy that starts but is broken does not update its setup yet.',
80
+ 'Write what the app needs in a JSON notes file, for example { "run": "<missing services, packages, toolchains or settings>" }, then run `haystack init --notes <file>` and `haystack verify` again. Haystack uses the notes to repair the existing setup and reruns the check when the repair is ready.',
83
81
  'Tell your user this check did not test the change; do not read it as passing.',
84
82
  ];
85
83
  /** Text that reads as an error, for the lines a broken copy's start screen shows (every one is in --raw). */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * How `haystack init` ends: a short checklist of what is done and what is left, then exactly one next step (Akshay,
3
+ * 2026-10-07: init as a wizard for coding agents, with no new commands). Codex setting up langgenius/dify got the test copy's
4
+ * setup, production telemetry's questions and hundreds of telemetry candidates in one output, and could not tell when setup
5
+ * was finished. What agent-facing CLIs do (doctor-style checklists, one actionable next step, never a prompt): init does all
6
+ * it can in one run, and stops only when it needs a person, needs to wait, or is done; running it again picks up from there.
7
+ *
8
+ * The next step is one of: a command the coding agent runs itself (`run`), something only its user can do or answer
9
+ * (`ask-user`, the text says what to ask), a long step to wait out (`wait`), or nothing left (`none`).
10
+ */
11
+ import chalk from 'chalk';
12
+ const TELEMETRY_HINT = 'optional: it needs the app\'s owner. Ask your user whether to track how real users use the app; if they '
13
+ + 'want it, run `haystack init --telemetry`';
14
+ function onboardingCheck(onboarding) {
15
+ const item = 'Test copy of the app';
16
+ if (onboarding === null)
17
+ return { item, state: 'todo', detail: 'not started yet' };
18
+ switch (onboarding.state) {
19
+ case 'ready': return { item, state: 'done', detail: 'ready' };
20
+ case 'onboarding': return { item, state: 'running', detail: 'being set up' };
21
+ case 'blocked': return { item, state: 'todo', detail: onboarding.block?.ask?.question ? 'needs an answer from your user' : 'blocked' };
22
+ case 'not-started': return { item, state: 'todo', detail: 'not started yet' };
23
+ default: return { item, state: 'todo', detail: 'stopped before finishing' };
24
+ }
25
+ }
26
+ function telemetryChecks(telemetry) {
27
+ if (telemetry === null)
28
+ return [{ item: 'Tracking real users', state: 'optional', detail: TELEMETRY_HINT }];
29
+ const part = (item, value) => value.status === 'unsupported'
30
+ ? { item, state: 'skipped', detail: `not supported here: ${value.why}` }
31
+ : value.status === 'installed'
32
+ ? value.received === null ? { item, state: 'todo', detail: 'set up in the code; nothing received from the live app yet' }
33
+ : { item, state: 'done', detail: `working; last received ${value.received.lastSeenAt}` }
34
+ : { item, state: 'todo', detail: 'needs a step' };
35
+ return [part('Tracking in the browser', telemetry.report.capture), part('Tracking on the server', telemetry.report.server)];
36
+ }
37
+ /** Tracking is finished when every part this app supports is set up and Haystack has received its data from the live app. The
38
+ * report's steps stay listed after that (the token, production's environment, the off switches), as standing advice, never as a
39
+ * next step: an agent running init until Ready would otherwise never reach it (Bugbot on #4224). */
40
+ function trackingDone(report) {
41
+ return [report.capture, report.server].every(part => part.status === 'unsupported' || (part.status === 'installed' && part.received !== null));
42
+ }
43
+ /** Tracking's first step for the person while it is not finished: a manual part's first step, else the first of the report's
44
+ * steps, with the indented lines that continue it. */
45
+ function firstTelemetryStep(telemetry) {
46
+ if (trackingDone(telemetry.report))
47
+ return null;
48
+ for (const value of [telemetry.report.capture, telemetry.report.server]) {
49
+ if (value.status === 'manual' && value.steps.length)
50
+ return value.steps[0];
51
+ }
52
+ const at = telemetry.steps.findIndex(step => !step.startsWith(' '));
53
+ if (at < 0)
54
+ return null;
55
+ const continued = telemetry.steps.slice(at + 1);
56
+ const end = continued.findIndex(step => !step.startsWith(' '));
57
+ return [telemetry.steps[at], ...(end < 0 ? continued : continued.slice(0, end)).map(step => step.trim())].join(' ');
58
+ }
59
+ function nextStep(input) {
60
+ if (input.status === 'login-required') {
61
+ return { kind: 'run', text: 'Run `haystack login`: it gives your user a link to sign in with. Then run `haystack init` again.', command: 'haystack login' };
62
+ }
63
+ if (input.status === 'planned') {
64
+ // Notes save the most time before the app's setup starts; for an app already set up they change nothing.
65
+ const notesHelp = input.notes === null && input.onboarding !== null && input.onboarding.state !== 'ready' && input.onboarding.state !== 'onboarding'
66
+ ? ' The app is not set up yet: add `--notes <file>` with what you know about how it runs, signs people in and is used '
67
+ + '(see `haystack init --help`), so its setup does not have to work that out.' : '';
68
+ return { kind: 'run', text: `Nothing was changed yet. Run \`${input.rerunWithYes}\` to make the changes above.${notesHelp}`, command: input.rerunWithYes };
69
+ }
70
+ if (input.status === 'app-required') {
71
+ return { kind: 'ask-user', text: `Ask your user to install the Haystack GitHub App on ${input.owner}: ${input.installUrl} . Then run \`haystack init\` again.`,
72
+ command: null };
73
+ }
74
+ const onboarding = input.onboarding;
75
+ if (onboarding.state === 'blocked') {
76
+ const block = onboarding.block;
77
+ const ask = block?.ask ?? null;
78
+ if (ask?.question) {
79
+ return { kind: 'ask-user', text: `Ask your user: ${ask.message} (one of: ${ask.question.choices.map(choice => JSON.stringify(choice)).join(', ')}). `
80
+ + `File their answer with \`haystack verify answer ${ask.question.id} "<their answer>"\`, then run \`haystack init\` again.`,
81
+ command: `haystack verify answer ${ask.question.id} "<their answer>"` };
82
+ }
83
+ if (ask?.command)
84
+ return { kind: 'run', text: `${ask.message} Run \`${ask.command}\`, then \`haystack init\` again.`, command: ask.command };
85
+ return { kind: 'none', text: `Setup is blocked and nothing you can run changes that yet${block ? `: ${block.reason}` : ''}. Tell your user, `
86
+ + 'and say what happened with `haystack feedback "<what happened>"`.', command: null };
87
+ }
88
+ if (onboarding.state !== 'ready' && onboarding.state !== 'onboarding') {
89
+ return { kind: 'run', text: 'Setting up the test copy stopped before finishing. Run `haystack init` again to start it again.', command: 'haystack init' };
90
+ }
91
+ // Something the person can do now comes before waiting.
92
+ const tracking = input.telemetry === null ? null : firstTelemetryStep(input.telemetry);
93
+ if (tracking !== null)
94
+ return { kind: 'ask-user', text: `For tracking: ${tracking}`, command: null };
95
+ if (onboarding.state === 'onboarding') {
96
+ return { kind: 'wait', text: 'The test copy is being set up. Run `haystack verify onboarding --wait` to follow its progress, then run `haystack init` again. '
97
+ + 'Ctrl-C stops waiting; setup keeps running.', command: 'haystack verify onboarding --wait' };
98
+ }
99
+ return { kind: 'none', text: 'Ready. After each change, run `haystack verify`.', command: null };
100
+ }
101
+ export function initSummary(input) {
102
+ const checkout = input.changes.length === 0 ? { item: 'This checkout', state: 'done', detail: 'already set up' }
103
+ : input.made ? { item: 'This checkout', state: 'done', detail: `updated ${input.changes.join(', ')}` }
104
+ : { item: 'This checkout', state: 'todo', detail: `${input.changes.join(', ')} not changed yet` };
105
+ const notes = input.notes === null ? []
106
+ : [{ item: 'Your notes', state: input.status === 'done' ? 'done' : 'todo', detail: `${input.status === 'done' ? 'saved' : 'not saved yet'} (${input.notes.join(', ')})` }];
107
+ const checklist = [checkout, ...notes, onboardingCheck(input.onboarding), ...telemetryChecks(input.telemetry)];
108
+ return { ready: input.status === 'done' && input.onboarding?.state === 'ready', checklist, next: nextStep(input) };
109
+ }
110
+ const MARKS = { done: chalk.green('✓'), running: chalk.cyan('…'), todo: chalk.yellow('✗'), skipped: chalk.dim('–'),
111
+ optional: chalk.dim('○') };
112
+ export function formatInitSummary(summary) {
113
+ return [chalk.bold('Checklist'), ...summary.checklist.map(check => ` ${MARKS[check.state]} ${check.item}: ${check.detail}`), '',
114
+ chalk.bold(`Next: ${summary.next.text}`)];
115
+ }
@@ -10,11 +10,14 @@
10
10
  * .git/info/exclude), when Claude Code is in use here;
11
11
  * 2. a short marked block in AGENTS.md (and in CLAUDE.md when the repository
12
12
  * keeps one) telling coding agents to run `haystack verify` after a change.
13
- * 3. telemetry (init-telemetry.ts, CAPTURE-V1 rule 8): the app's server instrumentation and the CLI dependency it needs,
14
- * the settings proposal and the browser tag, once the user's answers (which app, its origins, consent) are given;
15
- * until then only the telemetry report stops, saying what to ask.
13
+ * 3. with --telemetry only (or one of its answers: --app, --origin, --consent, --url-rewrites), telemetry
14
+ * (init-telemetry.ts, CAPTURE-V1 rule 8): the app's server instrumentation and the CLI dependency it needs, the
15
+ * settings proposal and the browser tag, once the user's answers are given; until then only the telemetry report
16
+ * stops, saying what to ask. It needs the app's owner (their yes, production's origins, a token, a deploy), so a
17
+ * plain init never plans it.
16
18
  * It then starts onboarding the app at the base `haystack verify` would use, so
17
- * the app is usually ready by the first verify. With --notes, the coding agent's
19
+ * the app is usually ready by the first verify. Every run ends with a short checklist and exactly one next step
20
+ * (init-summary.ts): init does all it can in one run and stops only when it needs a person, needs to wait, or is done. With --notes, the coding agent's
18
21
  * notes on how the app runs, signs people in and is used (onboarding-notes.ts)
19
22
  * become the repository's onboarding notes first, so onboarding plans from them. Running it again changes only
20
23
  * what is missing or out of date and shows where onboarding is.
@@ -36,7 +39,8 @@ import { findGitRoot } from '../utils/hooks.js';
36
39
  import { assertNoSymlinkOnPath } from '../utils/safe-write.js';
37
40
  import { CLAUDE_LOCAL_SETTINGS, CLAUDE_SHARED_SETTINGS, HAYSTACK_PRECOMPUTE_HOOK_COMMAND, claudeEntries, claudeSettingsPath, isCLIInstalled, isGitIgnored, isInstalledGlobally, isPrecomputeCommand, readClaudeSettings, } from './install-session-hooks.js';
38
41
  import { NOTE_FIELDS, readNotes } from './onboarding-notes.js';
39
- import { formatOnboarding, reportOnboardingState, startOnboarding, writeOnboardingNotes } from './verify-onboarding.js';
42
+ import { formatOnboarding, readOnboardingStatus, reportOnboardingState, startOnboarding, writeOnboardingNotes } from './verify-onboarding.js';
43
+ import { formatInitSummary, initSummary } from './init-summary.js';
40
44
  import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
41
45
  const NOTE_BEGIN = '<!-- haystack:begin -->';
42
46
  const NOTE_END = '<!-- haystack:end -->';
@@ -332,6 +336,8 @@ function applyAll(gitRoot, changes, say) {
332
336
  }
333
337
  }
334
338
  const errorText = (error) => (error instanceof Error ? error.message : String(error));
339
+ /** A value as one shell word: as it is when plain, else single-quoted. */
340
+ const shellWord = (value) => (/^[\w@%+=:,./-]+$/u.test(value) ? value : `'${value.replace(/'/gu, `'\\''`)}'`);
335
341
  function applyChange(gitRoot, planned) {
336
342
  const inside = relative(gitRoot, planned.target);
337
343
  if (!inside.startsWith('..') && !isAbsolute(inside))
@@ -395,18 +401,24 @@ export async function initCommand(options) {
395
401
  // Read before anything else, so notes that cannot be used stop init with nothing done.
396
402
  const notes = options.notes === undefined ? null : readNotes(options.notes);
397
403
  const plan = planInit(gitRoot);
398
- // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it. It never fails
399
- // init: whatever goes wrong with it (a file it cannot read, a package manager that fails, Haystack not answering) puts its
400
- // own files back and leaves it manual with what happened, and the rest of init goes on.
404
+ // Telemetry (CAPTURE-V1 rule 8) only when asked for: --telemetry, or one of its answers. It needs the app's owner, so a plain
405
+ // init, which a coding agent finishes alone, never plans it (Codex on langgenius/dify, 2026-10-07, got its questions and
406
+ // hundreds of candidates in the test copy's setup). A missing answer stops only it. It never fails init: whatever goes
407
+ // wrong with it (a file it cannot read, a package manager that fails, Haystack not answering) puts its own files back and
408
+ // leaves it manual with what happened, and the rest of init goes on.
409
+ const wantTelemetry = options.telemetry === true || options.app !== undefined || (options.origin?.length ?? 0) > 0
410
+ || options.consent !== undefined || options.urlRewrites !== undefined;
401
411
  const telemetryModule = await import('./init-telemetry.js');
402
412
  const telemetryOptions = { app: options.app, origins: options.origin ?? [], consent: options.consent, urlRewrites: options.urlRewrites };
403
413
  let telemetryFailure = null;
404
414
  let telemetryPlan = telemetryModule.emptyTelemetryPlan();
405
- try {
406
- telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
407
- }
408
- catch (error) {
409
- telemetryFailure = errorText(error);
415
+ if (wantTelemetry) {
416
+ try {
417
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
418
+ }
419
+ catch (error) {
420
+ telemetryFailure = errorText(error);
421
+ }
410
422
  }
411
423
  const telemetryFrom = plan.changes.length;
412
424
  plan.changes.push(...telemetryPlan.changes);
@@ -423,16 +435,32 @@ export async function initCommand(options) {
423
435
  const report = () => (telemetryFailure !== null ? telemetryModule.failedTelemetry(telemetryPlan, telemetryFailure)
424
436
  : telemetry ?? telemetryModule.previewTelemetry(telemetryPlan, repository, notes));
425
437
  const showPreview = () => {
438
+ if (!wantTelemetry)
439
+ return;
426
440
  const preview = report();
427
441
  for (const line of telemetryModule.formatTelemetry(preview.telemetry, preview.steps))
428
442
  say(line);
429
443
  say('');
430
444
  };
445
+ // This run's command again with --yes: what a run that only showed its changes asks for.
446
+ const rerunWithYes = ['haystack init --yes', ...(options.notes === undefined ? [] : [`--notes ${shellWord(options.notes)}`]),
447
+ ...(options.telemetry ? ['--telemetry'] : []), ...(options.app === undefined ? [] : [`--app ${shellWord(options.app)}`]),
448
+ ...(options.origin ?? []).map(value => `--origin ${shellWord(value)}`), ...(options.consent === undefined ? [] : [`--consent ${shellWord(options.consent)}`]),
449
+ ...(options.urlRewrites === undefined ? [] : [`--url-rewrites ${shellWord(options.urlRewrites)}`])].join(' ');
450
+ let made = false;
431
451
  const finish = (status, onboarding, exitCode) => {
452
+ const shown = wantTelemetry ? report() : null;
453
+ const summary = initSummary({ status, owner: origin.owner, changes: plan.changes.map(planned => planned.path), made,
454
+ notes: notes === null ? null : NOTE_FIELDS.filter(field => notes[field] !== ''), onboarding,
455
+ telemetry: shown === null ? null : { report: shown.telemetry, steps: shown.steps }, rerunWithYes, installUrl: HAYSTACK_APP_INSTALL_URL });
456
+ say('');
457
+ for (const line of formatInitSummary(summary))
458
+ say(line);
432
459
  if (options.json) {
433
460
  const changes = plan.changes.map(({ path, action, reason, diff }) => ({ path, action, reason, diff }));
434
- process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, changes, notices: plan.notices, notes: notesWritten,
435
- onboarding, telemetry: report().telemetry, telemetrySteps: report().steps }), null, 2)}\n`);
461
+ process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, ready: summary.ready, checklist: summary.checklist,
462
+ next: summary.next, changes, notices: plan.notices, notes: notesWritten, onboarding, telemetry: shown?.telemetry ?? null,
463
+ telemetrySteps: shown?.steps ?? [] }), null, 2)}\n`);
436
464
  }
437
465
  process.exitCode = exitCode;
438
466
  };
@@ -460,22 +488,34 @@ export async function initCommand(options) {
460
488
  if (!(error instanceof NotLoggedInError))
461
489
  throw error;
462
490
  showPreview();
463
- say(chalk.yellow('Not logged in, so nothing was changed. Run `haystack login`, then `haystack init` again.'));
491
+ say(chalk.yellow('Not logged in, so nothing was changed.'));
464
492
  finish('login-required', null, EXIT_CODES['login-required']);
465
493
  return;
466
494
  }
467
495
  if (plan.changes.length > 0 && !options.yes) {
468
- const interactive = !options.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
496
+ // Never a prompt for a coding agent, even in a terminal it drives (Claude Code sets CLAUDECODE; agents share AI_AGENT):
497
+ // the run stops and names the flag instead.
498
+ const agent = Boolean(process.env.CLAUDECODE) || Boolean(process.env.AI_AGENT);
499
+ const interactive = !options.json && !agent && process.stdin.isTTY === true && process.stdout.isTTY === true;
469
500
  if (!interactive || !await confirmed()) {
470
501
  showPreview();
471
- say('Nothing was changed. Run `haystack init --yes` to make these changes.');
472
- finish('planned', null, EXIT_CODES.planned);
502
+ // Where the app stands, read without starting anything, so the checklist says it and an agent writes notes only for an
503
+ // app that is not set up yet (Codex on langgenius/dify, 2026-10-07, wrote notes for an app already set up).
504
+ let standing = null;
505
+ try {
506
+ standing = await readOnboardingStatus(repository, deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS), token);
507
+ }
508
+ catch {
509
+ // Unread (the GitHub App not installed, Haystack not answering): the checklist says it has not started, as before.
510
+ }
511
+ finish('planned', standing, EXIT_CODES.planned);
473
512
  return;
474
513
  }
475
514
  }
476
515
  // Init's own changes (the agent instructions, the Stop hook) first, all or nothing: a failure there is init's.
477
516
  try {
478
517
  applyAll(gitRoot, plan.changes.slice(0, telemetryFrom), say);
518
+ made = true;
479
519
  }
480
520
  catch (error) {
481
521
  throw new Error(`${errorText(error)} Every file init changed was restored; nothing was changed.`);
@@ -485,7 +525,7 @@ export async function initCommand(options) {
485
525
  // without capture's, when it is refused).
486
526
  const approved = options.yes === true || plan.changes.length > 0;
487
527
  let capture = null;
488
- if (telemetryFailure === null) {
528
+ if (wantTelemetry && telemetryFailure === null) {
489
529
  try {
490
530
  capture = telemetryPlan.captureRegistration
491
531
  ? await telemetryModule.prepareCapture(telemetryPlan.captureRegistration, token, say, approved) : null;
@@ -527,27 +567,28 @@ export async function initCommand(options) {
527
567
  }
528
568
  catch (error) {
529
569
  if (error instanceof HaystackApiError && error.code === INSTALLATION_REQUIRED_CODE) {
530
- say(chalk.yellow(`Haystack needs its GitHub App on ${origin.owner} to read the repository. Install it at`));
531
- say(` ${HAYSTACK_APP_INSTALL_URL}`);
532
- say('then run `haystack init` again.');
570
+ say(chalk.yellow(`Haystack needs its GitHub App on ${origin.owner} to read the repository.`));
533
571
  finish('app-required', null, EXIT_CODES['app-required']);
534
572
  return;
535
573
  }
536
574
  const done = plan.changes.length > 0 ? 'The changes above were made, but onboarding' : 'Onboarding';
537
575
  throw new Error(`${done} could not start: ${error instanceof Error ? error.message : String(error)} Run \`haystack init\` again to start it.`);
538
576
  }
539
- // New notes for an app that is already set up change nothing about its setup (rule 2, amendment 34: each app is set up once).
540
- // Said in words, and in --json's notices for the coding agent, which otherwise reads "notes: written" as a new onboarding
541
- // (Codex on langgenius/dify, 2026-10-07).
577
+ // Notes inform an incremental repair when the next check finds a broken setup; init itself does not rebuild the app.
542
578
  if (notesWritten === 'written' && onboarding.state === 'ready') {
543
- const notice = 'The app is already set up, so these notes do not change its setup: Haystack sets each app up once and keeps the notes.';
544
- plan.notices.push(notice);
545
- say(chalk.dim(notice));
579
+ // This init call only saved the notes. The check, not init, starts an incremental repair when one is needed.
580
+ const notices = [
581
+ 'The app is already set up, so these notes do not change its setup: Haystack sets each app up once and keeps the notes.',
582
+ 'Run `haystack verify` again: if its copy is broken, Haystack uses the saved notes to repair the existing setup and rerun the check.',
583
+ ];
584
+ plan.notices.push(...notices);
585
+ for (const notice of notices)
586
+ say(chalk.dim(notice));
546
587
  }
547
588
  reportOnboardingState(onboarding);
548
589
  say(formatOnboarding(onboarding));
549
590
  say('');
550
- if (telemetryFailure === null) {
591
+ if (wantTelemetry && telemetryFailure === null) {
551
592
  try {
552
593
  telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
553
594
  }
@@ -557,15 +598,10 @@ export async function initCommand(options) {
557
598
  telemetry = { telemetry: preview.telemetry, steps: [...preview.steps, `Run \`haystack init --json\` again to read what telemetry Haystack has received (it could not: ${errorText(error)}).`] };
558
599
  }
559
600
  }
560
- const shown = report();
561
- for (const line of telemetryModule.formatTelemetry(shown.telemetry, shown.steps))
562
- say(line);
563
- say('');
564
- if (onboarding.state === 'ready')
565
- say(chalk.green('Ready. After your next change, run `haystack verify`.'));
566
- else if (onboarding.state === 'onboarding') {
567
- say(chalk.green('Haystack is preparing the app in the background; the first time can take half an hour.'));
568
- say('Keep working: `haystack verify` waits for it when needed, and `haystack verify onboarding --wait` follows it.');
601
+ if (wantTelemetry) {
602
+ const shown = report();
603
+ for (const line of telemetryModule.formatTelemetry(shown.telemetry, shown.steps))
604
+ say(line);
569
605
  }
570
606
  finish('done', onboarding, setUpExitCode(onboarding));
571
607
  }
@@ -117,6 +117,8 @@ export function parseOnboardingStatus(value, expected) {
117
117
  const recordUpdate = value.recordUpdate;
118
118
  if (!(recordUpdate === null || (ready && isRecord(recordUpdate) && typeof recordUpdate.of === 'string' && SHA256.test(recordUpdate.of)
119
119
  && typeof recordUpdate.crawlRunId === 'string' && (recordUpdate.side === 'base' || recordUpdate.side === 'head')
120
+ && (recordUpdate.cause === undefined || recordUpdate.cause === 'world-build-failed' || recordUpdate.cause === 'app-broken')
121
+ && (recordUpdate.cause !== 'app-broken' || recordUpdate.side === 'base')
120
122
  && typeof recordUpdate.workCommit === 'string' && COMMIT.test(recordUpdate.workCommit)
121
123
  && Array.isArray(recordUpdate.changes) && recordUpdate.changes.every(change => isRecord(change) && typeof change.field === 'string'
122
124
  && (change.name === null || typeof change.name === 'string') && (change.kind === 'added' || change.kind === 'changed')
@@ -125,6 +127,8 @@ export function parseOnboardingStatus(value, expected) {
125
127
  const update = value.update;
126
128
  if (!(update === null || (ready && isRecord(update) && typeof update.onboardRunId === 'string' && ONBOARD_RUN_ID.test(update.onboardRunId)
127
129
  && (update.side === 'base' || update.side === 'head') && typeof update.crawlRunId === 'string'
130
+ && (update.cause === 'world-build-failed' || update.cause === 'app-broken')
131
+ && (update.cause !== 'app-broken' || update.side === 'base')
128
132
  && (update.state === 'updating' || update.state === 'blocked' || update.state === 'stopped')
129
133
  && (update.state === 'blocked') === (update.block !== null))))
130
134
  invalid('its update');
@@ -349,18 +353,19 @@ export function formatOnboarding(status) {
349
353
  * line each, and a blocked update's block (what it needs, and what to tell Haystack). */
350
354
  export function updateLines(status) {
351
355
  const lines = [];
352
- const failed = (side) => side === 'base' ? 'the app did not start at its base commit' : 'the change did not start';
356
+ const failed = (side, cause) => cause === 'app-broken'
357
+ ? 'the app copy was broken' : side === 'base' ? 'the app did not start at its base commit' : 'the change did not start';
353
358
  if (status.recordUpdate) {
354
359
  const changes = status.recordUpdate.changes.map(change => `${change.kind} ${change.field === 'env' ? 'variable' : change.field}`
355
360
  + `${change.name === null ? '' : ` ${change.name}`}${change.evidence.length ? ` (from ${change.evidence.join(', ')})` : ''}`);
356
- lines.push(`Its setup was updated in place after check ${status.recordUpdate.crawlRunId}, where ${failed(status.recordUpdate.side)}`
361
+ lines.push(`Its setup was updated in place after check ${status.recordUpdate.crawlRunId}, where ${failed(status.recordUpdate.side, status.recordUpdate.cause)}`
357
362
  + `${changes.length ? `: ${changes.join('; ')}` : ''}.`);
358
363
  }
359
364
  const update = status.update;
360
365
  if (update === null)
361
366
  return lines;
362
367
  if (update.state === 'updating') {
363
- lines.push(`Updating the setup (${update.onboardRunId}): check ${update.crawlRunId} found ${failed(update.side)}. The check runs `
368
+ lines.push(`Updating the setup (${update.onboardRunId}): check ${update.crawlRunId} found ${failed(update.side, update.cause)}. The check runs `
364
369
  + 'again on the updated setup when it is ready.');
365
370
  }
366
371
  else if (update.state === 'stopped') {
@@ -370,7 +375,7 @@ export function updateLines(status) {
370
375
  else {
371
376
  lines.push(update.side === 'head'
372
377
  ? chalk.bold(`Finding: check ${update.crawlRunId}'s change does not start, and no change to the setup started it.`)
373
- : chalk.bold(`Haystack could not update the setup for check ${update.crawlRunId}, where the app did not start at its base commit.`));
378
+ : chalk.bold(`Haystack could not update the setup for check ${update.crawlRunId}, where ${failed(update.side, update.cause)}.`));
374
379
  if (update.block)
375
380
  lines.push(...blockLines(update.block));
376
381
  }
package/dist/index.js CHANGED
@@ -89,7 +89,8 @@ program
89
89
  .description('Set this repository up for `haystack verify` and start onboarding the app')
90
90
  .option('-y, --yes', 'Make the changes without asking')
91
91
  .option('--notes <file>', 'A JSON file (- for stdin) of what you know about running the app; see below')
92
- .option('--app <dir>', 'The app whose telemetry init sets up, when the repository has several; see below')
92
+ .option('--telemetry', 'Also set up tracking of how real users use the live app (optional; needs the app\'s owner); see below')
93
+ .option('--app <dir>', 'With --telemetry: the app whose telemetry init sets up, when the repository has several')
93
94
  .option('--origin <url>', 'A production origin of the app (repeat for several): browser capture accepts events only from these', (value, previous = []) => [...previous, value])
94
95
  .option('--consent <choice>', 'How the site asks visitors for consent: its consent platform, or not-required when your user decided so')
95
96
  .option('--url-rewrites <answer>', 'When init asks: none, or hidden-segment:<name> when the app\'s middleware adds a leading [<name>] segment to page paths')
@@ -105,12 +106,19 @@ it with --yes (or a yes at the prompt):
105
106
  • a short note in AGENTS.md (and CLAUDE.md when the repository keeps one)
106
107
  telling coding agents to run \`haystack verify\` after a change
107
108
  Then it starts onboarding the app, so it is usually ready by the first
108
- \`haystack verify\`. Running it again changes only what is missing and shows
109
- where onboarding is (\`haystack verify onboarding\` shows that too).
109
+ \`haystack verify\`.
110
+
111
+ Every run does all it can, then ends with a short checklist and exactly one
112
+ "Next:" line: a command to run, a question to ask your user, a wait, or
113
+ "Ready". Keep running what Next says until it says Ready; running init again
114
+ changes only what is missing and picks up where it stopped. It never asks a
115
+ yes/no question when a coding agent runs it: it shows the changes and says to
116
+ run it again with --yes.
110
117
 
111
118
  --notes tells onboarding what a coding agent working here already knows, so it
112
119
  does not have to work it out (a wrong guess at the start command can cost half
113
- an hour). The file is JSON with any of three notes, each plain text:
120
+ an hour). Run \`haystack init\` without --yes first: it changes nothing and says
121
+ whether the app is already set up, when notes are not needed. The file is JSON with any of three notes, each plain text:
114
122
  run how production installs, builds and starts the app: the commands
115
123
  and their directory, the port, the databases and services and their
116
124
  versions, migrations, the environment variables it needs (names and
@@ -123,10 +131,13 @@ onboarding (an onboarding that stopped on a question starts again with them);
123
131
  after it, the next check that cannot start the app uses them to update the
124
132
  setup in place.
125
133
 
126
- Every run also plans telemetry for the app: --app names it when the repository
127
- has several (init never picks one), --origin its production origins and
128
- --consent the consent choice; until they are given, only telemetry waits and
129
- its report says what to ask. Server telemetry: a Next.js app's next.config
134
+ --telemetry (optional, after the app is ready) also sets up tracking of how
135
+ real users use the live app. It needs the app's owner: their yes, the app's
136
+ live web address, a token and a deploy, so a plain init never does it. --app
137
+ names the app when the repository has several (init never picks one), --origin
138
+ its production origins and --consent the consent choice (each implies
139
+ --telemetry); until they are given, only telemetry waits and Next says what
140
+ to ask. Server telemetry: a Next.js app's next.config
130
141
  gains the withHaystackTelemetry wrapper in its own export form; a Node server
131
142
  whose build ends in a \`tsc\` compile gains \`haystack telemetry instrument\`
132
143
  after it; the CLI becomes a checked, exact-version dependency through the
@@ -134,10 +145,11 @@ app's package manager; \`haystack telemetry settings --propose\` writes a
134
145
  settings proposal that only your user approves (\`--approve\`). Each is a
135
146
  change shown first like the others, and a failed install restores every file.
136
147
  init never mints the ingest token: a repository admin runs
137
- \`haystack telemetry token\` themselves. \`--json\` reports \`telemetry\`
138
- (each part installed, manual or unsupported, and what Haystack last received)
139
- and \`telemetrySteps\` (what the person does next, including the off switch
140
- HAYSTACK_TELEMETRY=0).
148
+ \`haystack telemetry token\` themselves. With --telemetry, \`--json\` reports
149
+ \`telemetry\` (each part installed, manual or unsupported, and what Haystack
150
+ last received) and \`telemetrySteps\` (what the person does next, including
151
+ the off switch HAYSTACK_TELEMETRY=0); \`--json\` always reports \`ready\`,
152
+ \`checklist\` and \`next\`.
141
153
 
142
154
  Browser capture: --consent is cookiebot, onetrust:<category id>, not-required
143
155
  or manual. init adds the self-hosted tag, its consent wiring and the build
@@ -159,6 +171,7 @@ Examples:
159
171
  haystack init
160
172
  haystack init --yes
161
173
  haystack init --yes --json --notes haystack-notes.json
174
+ haystack init --telemetry
162
175
  `)
163
176
  .action(options => runPublicCommand(() => initCommand(options), options.json));
164
177
  const verify = program
@@ -215,9 +228,11 @@ own setup, its own requests failing in both builds), the crawl could not test
215
228
  your change, and it says so first: "We couldn't test your change: the app copy
216
229
  is broken.", why, what the start screen showed, the requests that failed and
217
230
  the console errors, and what to do: find what those errors need in the
218
- repository's own setup (docker compose files, env examples, docs), send that
219
- with \`haystack feedback\` (a copy that starts but is broken does not update its
220
- setup yet), and tell your user the change was not tested. What the crawl
231
+ repository's own setup (docker compose files, env examples, docs), write that
232
+ in the "run" field of a JSON notes file, then run \`haystack init --notes <file>\`
233
+ and \`haystack verify\` again. Haystack repairs the existing setup with those
234
+ notes and reruns the check when ready. Tell your user this check did not test
235
+ the change. What the crawl
221
236
  found in the broken copy is listed after it, marked as such.
222
237
 
223
238
  The first verify on a base the service has not onboarded yet prepares the app
package/dist/schema.js CHANGED
@@ -17,7 +17,7 @@ export const SCHEMA_VERSIONS = {
17
17
  'verify-raw': '1.3.0',
18
18
  'verify-answer': '1.0.1',
19
19
  'verify-onboarding': '1.0.0',
20
- init: '1.0.2',
20
+ init: '2.0.0',
21
21
  login: '1.0.0',
22
22
  feedback: '1.0.0',
23
23
  error: '1.0.0',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.30.2",
3
+ "version": "0.31.0",
4
4
  "description": "haystack verify: run your app with and without a change, and see what the change broke",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,299 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://docs.haystackeditor.com/cli/schemas/init.v2.json",
4
+ "title": "Haystack init: this repository's setup for `haystack verify`",
5
+ "description": "Machine-readable output of `haystack init --json`. `changes` are the project-local changes init planned (made when status is done); `onboarding` is the base's OnboardingStatusV1 (onboarding_contracts.ts, APP-ONBOARDING-V1) as POST /api/agent/cloud-verifier/onboarding returns it, null unless status is done. `notes` says what became of `--notes`: \"written\" (the repository's onboarding notes are now these), \"unchanged\" (they already were), null without `--notes` or when init stopped before writing them. Exit codes: 0 done (onboarding running or ready), 1 failed (the error envelope), 2 planned (nothing written; run with --yes), 3 done but onboarding blocked, 4 login-required, 5 app-required (install the Haystack GitHub App), 6 done but onboarding stopped before finishing (run init again). `telemetry` (CAPTURE-V1 rule 8e, InitTelemetryReport in capture_contracts.ts) is what telemetry does (status done) or will do (planned, login-required: the same report before anything is written, every `received` null because nothing was checked): per part (`capture`, the browser tag; `server`, server telemetry) `installed`, `manual` with the exact `steps`, or `unsupported` with `why`, and `received`, the newest telemetry Haystack holds for this application (null when none; `stale` past 24 hours), plus `dbProfileCommand`, the `haystack db profile` command for the user to run with production read access (null when the app's database is not one it profiles). `telemetrySteps` is what the person does next for telemetry to arrive, in order, including the off switches; [] without telemetry. 2.0.0 (2026-10-07): every run ends with `ready`, a `checklist` and exactly one `next` step; telemetry is planned only with --telemetry (or one of its answers), so `telemetry` is null and `telemetrySteps` empty without it.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema_version",
10
+ "status",
11
+ "repository",
12
+ "ready",
13
+ "checklist",
14
+ "next",
15
+ "changes",
16
+ "notices",
17
+ "notes",
18
+ "onboarding",
19
+ "telemetry",
20
+ "telemetrySteps"
21
+ ],
22
+ "properties": {
23
+ "schema_version": {
24
+ "const": "2.0.0"
25
+ },
26
+ "status": {
27
+ "enum": [
28
+ "done",
29
+ "planned",
30
+ "login-required",
31
+ "app-required"
32
+ ]
33
+ },
34
+ "repository": {
35
+ "type": "string"
36
+ },
37
+ "ready": {
38
+ "type": "boolean",
39
+ "description": "The test copy of the app is set up: run `haystack verify` after each change."
40
+ },
41
+ "checklist": {
42
+ "type": "array",
43
+ "description": "What is done and what is left, in order.",
44
+ "items": {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "required": [
48
+ "item",
49
+ "state",
50
+ "detail"
51
+ ],
52
+ "properties": {
53
+ "item": {
54
+ "type": "string"
55
+ },
56
+ "state": {
57
+ "enum": [
58
+ "done",
59
+ "running",
60
+ "todo",
61
+ "skipped",
62
+ "optional"
63
+ ]
64
+ },
65
+ "detail": {
66
+ "type": "string"
67
+ }
68
+ }
69
+ }
70
+ },
71
+ "next": {
72
+ "type": "object",
73
+ "additionalProperties": false,
74
+ "required": [
75
+ "kind",
76
+ "text",
77
+ "command"
78
+ ],
79
+ "description": "Exactly one next step: run (a command the coding agent runs), ask-user (only the user can do or answer it; text says what to ask), wait (a long step to wait out, then run `command`), none (nothing left, or nothing that can be run).",
80
+ "properties": {
81
+ "kind": {
82
+ "enum": [
83
+ "run",
84
+ "ask-user",
85
+ "wait",
86
+ "none"
87
+ ]
88
+ },
89
+ "text": {
90
+ "type": "string"
91
+ },
92
+ "command": {
93
+ "type": [
94
+ "string",
95
+ "null"
96
+ ]
97
+ }
98
+ }
99
+ },
100
+ "changes": {
101
+ "type": "array",
102
+ "items": {
103
+ "type": "object",
104
+ "additionalProperties": false,
105
+ "required": [
106
+ "path",
107
+ "action",
108
+ "reason",
109
+ "diff"
110
+ ],
111
+ "properties": {
112
+ "path": {
113
+ "type": "string"
114
+ },
115
+ "action": {
116
+ "enum": [
117
+ "create",
118
+ "update",
119
+ "delete"
120
+ ]
121
+ },
122
+ "reason": {
123
+ "type": "string"
124
+ },
125
+ "diff": {
126
+ "type": "array",
127
+ "items": {
128
+ "type": "string"
129
+ }
130
+ }
131
+ }
132
+ }
133
+ },
134
+ "notices": {
135
+ "type": "array",
136
+ "items": {
137
+ "type": "string"
138
+ }
139
+ },
140
+ "notes": {
141
+ "oneOf": [
142
+ {
143
+ "type": "null"
144
+ },
145
+ {
146
+ "enum": [
147
+ "written",
148
+ "unchanged"
149
+ ]
150
+ }
151
+ ]
152
+ },
153
+ "onboarding": {
154
+ "oneOf": [
155
+ {
156
+ "type": "null"
157
+ },
158
+ {
159
+ "$ref": "verify.v1.json#/$defs/onboardingStatus"
160
+ }
161
+ ]
162
+ },
163
+ "telemetry": {
164
+ "description": "Null unless telemetry was asked for (--telemetry or one of its answers).",
165
+ "anyOf": [
166
+ {
167
+ "type": "null"
168
+ },
169
+ {
170
+ "oneOf": [
171
+ {
172
+ "type": "null"
173
+ },
174
+ {
175
+ "type": "object",
176
+ "additionalProperties": false,
177
+ "required": [
178
+ "capture",
179
+ "server",
180
+ "dbProfileCommand"
181
+ ],
182
+ "properties": {
183
+ "capture": {
184
+ "$ref": "#/$defs/telemetryPart"
185
+ },
186
+ "server": {
187
+ "$ref": "#/$defs/telemetryPart"
188
+ },
189
+ "dbProfileCommand": {
190
+ "type": [
191
+ "string",
192
+ "null"
193
+ ]
194
+ }
195
+ }
196
+ }
197
+ ]
198
+ }
199
+ ]
200
+ },
201
+ "telemetrySteps": {
202
+ "type": "array",
203
+ "items": {
204
+ "type": "string"
205
+ }
206
+ }
207
+ },
208
+ "$defs": {
209
+ "telemetryPart": {
210
+ "oneOf": [
211
+ {
212
+ "type": "object",
213
+ "additionalProperties": false,
214
+ "required": [
215
+ "status",
216
+ "received"
217
+ ],
218
+ "properties": {
219
+ "status": {
220
+ "const": "installed"
221
+ },
222
+ "received": {
223
+ "$ref": "#/$defs/telemetryReceived"
224
+ }
225
+ }
226
+ },
227
+ {
228
+ "type": "object",
229
+ "additionalProperties": false,
230
+ "required": [
231
+ "status",
232
+ "steps",
233
+ "received"
234
+ ],
235
+ "properties": {
236
+ "status": {
237
+ "const": "manual"
238
+ },
239
+ "steps": {
240
+ "type": "array",
241
+ "items": {
242
+ "type": "string"
243
+ }
244
+ },
245
+ "received": {
246
+ "$ref": "#/$defs/telemetryReceived"
247
+ }
248
+ }
249
+ },
250
+ {
251
+ "type": "object",
252
+ "additionalProperties": false,
253
+ "required": [
254
+ "status",
255
+ "why"
256
+ ],
257
+ "properties": {
258
+ "status": {
259
+ "const": "unsupported"
260
+ },
261
+ "why": {
262
+ "type": "string"
263
+ }
264
+ }
265
+ }
266
+ ]
267
+ },
268
+ "telemetryReceived": {
269
+ "oneOf": [
270
+ {
271
+ "type": "null"
272
+ },
273
+ {
274
+ "type": "object",
275
+ "additionalProperties": false,
276
+ "required": [
277
+ "lastSeenAt",
278
+ "release",
279
+ "stale"
280
+ ],
281
+ "properties": {
282
+ "lastSeenAt": {
283
+ "type": "string"
284
+ },
285
+ "release": {
286
+ "type": [
287
+ "string",
288
+ "null"
289
+ ]
290
+ },
291
+ "stale": {
292
+ "type": "boolean"
293
+ }
294
+ }
295
+ }
296
+ ]
297
+ }
298
+ }
299
+ }