@haystackeditor/cli 0.27.1 → 0.27.2

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.
package/README.md CHANGED
@@ -41,10 +41,13 @@ or `HAYSTACK_TELEMETRY_DISABLED=1` to turn this telemetry off.
41
41
 
42
42
  Most users of this CLI are coding agents. What to know:
43
43
 
44
- - **After a change, run `haystack verify`** inside the checkout. Nothing needs
45
- to be committed or pushed. Say what you were asked to do with
46
- `--intent "<the task, in the user's words>"`, and what to try with
47
- `--idea "<as you would tell a tester>"` (repeat it for more).
44
+ - **After a change, two steps** inside the checkout (nothing needs to be
45
+ committed or pushed):
46
+ 1. `haystack pre-verify` shows what the change touches, what production and
47
+ real users do there, and Haystack's ideas of what could break.
48
+ 2. `haystack verify --intent "<the task, in the user's words>" --idea "<as you
49
+ would tell a tester>"` explores the app with your steering: keep the ideas
50
+ worth trying, add your own (repeat `--idea`); yours are explored first.
48
51
  - **First time in a repository**, `haystack init --yes` sets it up and starts
49
52
  onboarding the app. `haystack verify onboarding` shows where onboarding is.
50
53
  When onboarding asks a question, answer it with
@@ -74,10 +77,11 @@ It then finds your crawl of that exact capture: the same code and the same
74
77
  change title (HEAD's subject, which tells the crawl's judge what the change is;
75
78
  committing or rewording keeps the code but changes the title). When there is
76
79
  none, or the one it finds was cancelled, stopped before finishing or is being
77
- cancelled, it submits the capture through the same request `haystack verify
78
- precompute` sends (the service starts the crawl, or runs the stopped one again
79
- once it has shut down), says so in one line, and follows the crawl the service
80
- acknowledged; it never shows a crawl of another revision or another title.
80
+ cancelled, or the stop hook only built it, it asks the service for the crawl
81
+ (the service starts it, runs the stopped one again once it has shut down, or
82
+ turns the hook's prepared run into a crawl that starts from the app the hook
83
+ already built), says so in one line, and follows that crawl; it never shows a
84
+ crawl of another revision or another title.
81
85
 
82
86
  A crawl builds the app with and without your change, starts from the changed
83
87
  code, reaches it in the running app, explores outward with both builds side by
@@ -108,18 +112,20 @@ up, what became of each idea, and what never ran (`haystack schema verify`).
108
112
  it is large (`haystack schema verify-raw`). Exit codes: 0 when the crawl
109
113
  answered or finished (the bugs
110
114
  it found are in the output) or is still running under `--no-wait`; 2 when it ended
111
- without finishing (stopped early, or cancelled because a newer stop in the
112
- repository replaced it) or the machines it used could not be proven shut down;
115
+ without finishing (stopped early, or cancelled) or the machines it used could
116
+ not be proven shut down;
113
117
  1 when the command failed or no crawl could be started.
114
118
 
115
119
  **The stop hook.** `haystack init` installs a Claude Code Stop hook that runs
116
120
  `haystack verify precompute --hook` whenever an agent turn stops. It captures
117
- the checkout within five seconds and hands the request to a detached sender;
118
- the service then starts the change's crawl in the background, so
119
- `haystack verify` usually finds the crawl already running or finished. A newer
120
- stop in the same repository replaces an older crawl that has not finished.
121
- `haystack verify precompute` (without `--hook`) submits the same capture in the
122
- foreground and prints one line for the crawl it started.
121
+ the checkout within five seconds and hands the request to a detached sender.
122
+ The service then builds the app with and without the change, works out what
123
+ the change touches (the brief `haystack pre-verify` prints) and sets up starting
124
+ points next to it, and stops there: it never crawls. `haystack verify` then
125
+ crawls from that prepared app instead of building it again. No run cancels
126
+ another: each runs to its end unless it is cancelled. `haystack verify precompute`
127
+ (without `--hook`) submits the same capture in the foreground and prints one
128
+ line for the run it started.
123
129
 
124
130
  Retained fleet cases remain available through their exact case IDs:
125
131
 
@@ -45,16 +45,21 @@ export const AGENT_NOTE = [
45
45
  NOTE_BEGIN,
46
46
  '## Haystack',
47
47
  '',
48
- 'After you change this app\'s code, or when the user asks you to check a change, run `haystack verify` before you',
49
- 'call the work done. It runs the app with and without the change, explores it, and reports where the change shows up',
50
- 'and what broke. It changes nothing, so run it without asking.',
48
+ 'After you change this app\'s code, or when the user asks you to check a change, check it with Haystack before you call',
49
+ 'the work done. It runs the app with and without the change, explores it, and reports what broke. It changes nothing,',
50
+ 'so run it without asking.',
51
51
  '',
52
- '- It often takes 10 to 15 minutes: give it your longest timeout or run it in the background, and do not pipe it',
52
+ '1. `haystack pre-verify`: what your change touches, what production and real users do there, and Haystack\'s ideas',
53
+ ' of what could break.',
54
+ '2. `haystack verify --intent "<what you were asked to do, in the user\'s words>" --idea "<something to try>"`: keep the',
55
+ ' ideas worth trying, add your own (repeat --idea); yours are explored first.',
56
+ '',
57
+ '- `verify` often takes 10 to 15 minutes: give it your longest timeout or run it in the background, and do not pipe it',
53
58
  ' through `tail` or `head`, which hide its progress. If your command times out, run it again: it picks up the same crawl.',
54
59
  '- Fix each bug it reports that your change caused, then run `haystack verify` again.',
55
60
  '- If it says onboarding needs an answer, ask the user that question and file their choice with',
56
61
  ' `haystack verify answer <question-id> <choice>`.',
57
- '- Read its short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
62
+ '- Read verify\'s short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
58
63
  ' `haystack verify --help` explains the output and exit codes.',
59
64
  '- If `haystack` is not installed, run each command as `npx -y -p @haystackeditor/cli@latest haystack <command>`.',
60
65
  '- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
@@ -511,7 +516,8 @@ export async function initCommand(options) {
511
516
  }
512
517
  let onboarding;
513
518
  try {
514
- // The notes first: onboarding plans from the facts it starts with, and new notes are new facts (a new onboarding).
519
+ // The notes first: onboarding plans from the facts it starts with (rule 2, amendment 34: new notes onboard again only while
520
+ // the repository has no setup, after a run that ended without one).
515
521
  if (notes) {
516
522
  notesWritten = await writeOnboardingNotes(repository, notes, token);
517
523
  say(notesWritten === 'written' ? chalk.green('✓ Wrote the onboarding notes') : chalk.dim('The onboarding notes are already these.'));
@@ -106,6 +106,13 @@ export function parseOnboardingStatus(value, expected) {
106
106
  invalid('its review');
107
107
  if (value.state !== 'ready' && review !== null)
108
108
  invalid('its review');
109
+ // Rule 2 (amendment 34): a ready status names the base its record was made at and when it was published.
110
+ const ready = value.state === 'ready';
111
+ if (ready ? !(typeof value.recordBaseCommit === 'string' && COMMIT.test(value.recordBaseCommit)) : value.recordBaseCommit !== null)
112
+ invalid('its record base');
113
+ if (ready ? !(typeof value.recordPublishedAt === 'string' && !Number.isNaN(Date.parse(value.recordPublishedAt)))
114
+ : value.recordPublishedAt !== null)
115
+ invalid('its record time');
109
116
  if (value.block !== null)
110
117
  parseOnboardingBlock(value.block);
111
118
  const checkpoint = value.checkpoint;
@@ -147,8 +154,8 @@ async function body(response, what) {
147
154
  throw new OnboardingResponseError(`The onboarding service returned ${what} that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
148
155
  }
149
156
  }
150
- /** The onboarding of `repository` at `baseCommit`, now; with `onboardRunId`, that run's (or the run a derivation change moved
151
- * it to), whatever derivation is live: what a waiting command follows. */
157
+ /** The onboarding of `repository`, asked at `baseCommit` (rule 2, amendment 34: one setup per repository, the same at every
158
+ * base); with `onboardRunId`, that run's, or the repository's record once it has one: what a waiting command follows. */
152
159
  export async function readOnboardingStatus(repository, baseCommit, token, onboardRunId = null) {
153
160
  if (!COMMIT.test(baseCommit))
154
161
  throw new Error(`Invalid base commit ${baseCommit}.`);
@@ -159,7 +166,7 @@ export async function readOnboardingStatus(repository, baseCommit, token, onboar
159
166
  throw await classifyHttpError(response, `Haystack API ${path}`);
160
167
  return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
161
168
  }
162
- /** Rule 12's start POST: onboard this base now, before any change exists. Idempotent: a
169
+ /** Rule 12's start POST: onboard the repository now, at this base, before any change exists. Idempotent: a
163
170
  * ready, blocked or running onboarding is answered as it is. */
164
171
  export async function startOnboarding(repository, baseCommit, token) {
165
172
  if (!COMMIT.test(baseCommit))
@@ -198,8 +205,7 @@ export async function waitForOnboarding(first, token, progress, resume) {
198
205
  await delay(ONBOARDING_READ_EVERY_MS);
199
206
  let next;
200
207
  try {
201
- // The run this wait follows, not the base under whatever derivation is live: a release swap mid-wait otherwise reads
202
- // not-started while the run goes on to admit this command's crawl (galaxy's n8n replays, 2026-10-03).
208
+ // The run this wait follows, until the repository has a record.
203
209
  next = await readOnboardingStatus(status.repository, status.baseCommit, token, status.onboardRunId);
204
210
  }
205
211
  catch (error) {
@@ -282,16 +288,16 @@ export function blockLines(block) {
282
288
  return lines;
283
289
  }
284
290
  export function formatOnboarding(status) {
285
- const lines = [chalk.dim(` Onboarding ${status.onboardRunId ?? '(none)'} of ${safe(status.repository)} at base ${status.baseCommit.slice(0, 12)}`)];
291
+ const lines = [chalk.dim(` Onboarding ${status.onboardRunId ?? '(none)'} of ${safe(status.repository)}, asked at base ${status.baseCommit.slice(0, 12)}`)];
286
292
  switch (status.state) {
287
293
  case 'ready':
288
- lines.push('The app is onboarded for this base.');
294
+ lines.push(readyLine(status));
289
295
  break;
290
296
  case 'onboarding':
291
297
  lines.push(onboardingStep(status));
292
298
  break;
293
299
  case 'not-started':
294
- lines.push('Onboarding has not started for this base. `haystack init` starts it.');
300
+ lines.push('Onboarding has not started for this repository. `haystack init` starts it.');
295
301
  break;
296
302
  case 'incomplete':
297
303
  lines.push(`Onboarding stopped before finishing${status.checkpoint?.error ? `: ${safe(status.checkpoint.error.message)}` : ''}.`
@@ -319,6 +325,20 @@ export function formatOnboarding(status) {
319
325
  lines.push('', ...unreviewed.map(note => chalk.yellow(`Note: ${safe(note)}`)));
320
326
  return lines.join('\n');
321
327
  }
328
+ /** Rule 2 (amendment 34): the repository's one setup, which base it was made at and how long ago, in one sentence. */
329
+ export function readyLine(status, now = Date.now()) {
330
+ if (status.recordBaseCommit === null || status.recordPublishedAt === null)
331
+ return 'The app is onboarded.';
332
+ return `The app is onboarded: its setup was made at base ${status.recordBaseCommit.slice(0, 12)} `
333
+ + `${age(now - Date.parse(status.recordPublishedAt))} and serves every base.`;
334
+ }
335
+ /** "just now", "5 minutes ago", "3 hours ago", "2 days ago". */
336
+ function age(ms) {
337
+ const minutes = Math.floor(Math.max(0, ms) / 60_000);
338
+ const [count, unit] = minutes < 1 ? [0, ''] : minutes < 60 ? [minutes, 'minute'] : minutes < 2_880 ? [Math.floor(minutes / 60), 'hour']
339
+ : [Math.floor(minutes / 1_440), 'day'];
340
+ return count === 0 ? 'just now' : `${count} ${unit}${count === 1 ? '' : 's'} ago`;
341
+ }
322
342
  /** Rule 17 (amendment 32): an onboarding the independent review skipped, as the sentence every result shows; none otherwise. */
323
343
  export function reviewNotes(review) {
324
344
  if (review?.verdict !== 'skipped')
@@ -397,7 +417,7 @@ export async function verifyAnswerCommand(questionId, choice, options) {
397
417
  }
398
418
  }
399
419
  /** Makes the coding agent's notes the repository's onboarding notes (rule 7, amendment 18), unless they are exactly these
400
- * already: 'unchanged' then, so running init again with the same notes starts no new onboarding. */
420
+ * already: 'unchanged' then, so running init again with the same notes changes no facts. */
401
421
  export async function writeOnboardingNotes(repository, notes, token) {
402
422
  for (let attempt = 1;; attempt += 1) {
403
423
  const facts = await readFacts(repository, token);
@@ -486,7 +486,7 @@ function printResult(result) {
486
486
  else if (crawl?.status === 'unavailable')
487
487
  console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
488
488
  else if (crawl?.status === 'onboarding') {
489
- console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); this change is built and frozen when it is ready.`
489
+ console.log(`The app is being onboarded (${crawl.onboardRunId}); this change is built and frozen when it is ready.`
490
490
  + ' Run `haystack verify` to check it.');
491
491
  }
492
492
  else if (crawl?.status === 'onboarding-blocked')
@@ -32,7 +32,7 @@ import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
32
32
  import { CRAWL_BRIEF_MAX_FUNCTIONS, CRAWL_BRIEF_MAX_GROUPS, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_IDEAS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_MAX_TEXT_CHARS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
33
33
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
34
34
  import { SPOT_WORDS, TERMINAL, VERDICT_WORDS, VERDICTS, currentStep, errorWords, headline, plural, reportedStatus, results, verifyReport, } from './crawl-report.js';
35
- import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, reviewNotes, standInGapNotes, waitForOnboarding, } from './verify-onboarding.js';
35
+ import { formatOnboarding, onboardingExitCode, readOnboardingStatus, readyLine, reportOnboardingState, reviewNotes, standInGapNotes, waitForOnboarding, } from './verify-onboarding.js';
36
36
  import { formatCapture, readPreVerifyCapture } from './capture-brief.js';
37
37
  /** A held read (waitAfter): the service's hold, then the time any read gets. */
38
38
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
@@ -660,11 +660,11 @@ async function submitCapture(capture, token, found) {
660
660
  line: `${before}, and ${found === null ? 'one' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
661
661
  }
662
662
  if (crawl.status === 'onboarding') {
663
- return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded for this base yet: `
663
+ return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded yet: `
664
664
  + `the service is preparing it (${crawl.onboardRunId}) and runs the crawl when it is ready.` };
665
665
  }
666
666
  if (crawl.status === 'onboarding-blocked') {
667
- return { runId: null, onboarding: 'onboarding-blocked', line: `${before}, and the app's onboarding for this base is blocked.` };
667
+ return { runId: null, onboarding: 'onboarding-blocked', line: `${before}, and the app's onboarding is blocked.` };
668
668
  }
669
669
  if (found === null) {
670
670
  return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
@@ -834,9 +834,12 @@ export function formatBrief(brief) {
834
834
  lines.push(` ${safe(change.changeKey)}: ${safe(change.reason)}`);
835
835
  }
836
836
  }
837
- lines.push('', 'Next: pick the ideas worth trying, add your own, and say what you were asked to do (the task in the user\'s words, not what your code does):', ' haystack verify --intent "<what you were asked to do>" --idea "<something to try>" [--idea ...]', 'Your ideas are explored first; the ideas above are explored in every crawl too.');
838
837
  return lines.join('\n');
839
838
  }
839
+ /** The handshake's question, asked after everything pre-verify shows. */
840
+ const STEER_LINES = ['', 'Next: pick the ideas worth trying, add your own, and say what you were asked to do (the task in the user\'s words, not what your code does):',
841
+ ' haystack verify --intent "<what you were asked to do>" --idea "<something to try>" [--idea ...]',
842
+ 'Your ideas are explored first; the ideas above are explored in every crawl too.'];
840
843
  /** `haystack pre-verify` (CRAWL-V1 amendment 18): the brief of the current change, before any crawl. It captures the checkout as
841
844
  * the turn-end hook does, prepares it when no run of that capture is under way or done (it never widens a run to a crawl), and
842
845
  * prints the run's brief. Exit codes: 0 with a brief, 2 when the run ended without one, 3 onboarding blocked, 1 otherwise. */
@@ -886,7 +889,7 @@ export async function preVerifyCommand(options) {
886
889
  process.exitCode = onboardingExitCode(onboarding);
887
890
  return;
888
891
  }
889
- note('The app is onboarded for this base.');
892
+ note(readyLine(onboarding));
890
893
  submitted = await submitCapture(capture, auth.token, found);
891
894
  note(submitted.line);
892
895
  }
@@ -920,6 +923,8 @@ export async function preVerifyCommand(options) {
920
923
  if (!options.json && captured !== null)
921
924
  for (const section of captured)
922
925
  console.log(`\n${formatCapture(section)}`);
926
+ if (!options.json && brief !== null)
927
+ console.log(STEER_LINES.join('\n'));
923
928
  if (brief === null && TERMINAL.has(reportedStatus(view)))
924
929
  process.exitCode = 2;
925
930
  }
@@ -981,9 +986,9 @@ export async function verifyCommand(options) {
981
986
  finishWithOnboarding(onboarding, options, change);
982
987
  return;
983
988
  }
984
- // Ready: the service admitted the waiting crawl when the record published; this
985
- // submission of the same capture finds it (or starts it, when a newer stop replaced it).
986
- note('The app is onboarded for this base.');
989
+ // Ready: the service admitted the waiting crawl when the record published (amendment 12:
990
+ // every waiting request is admitted); this submission of the same capture finds it.
991
+ note(readyLine(onboarding));
987
992
  submitted = await submitCapture(capture, auth.token, found);
988
993
  note(submitted.line);
989
994
  }
package/dist/index.js CHANGED
@@ -119,7 +119,9 @@ an hour). The file is JSON with any of three notes, each plain text:
119
119
  signIn how a person gets an account and signs in, and the kinds of users
120
120
  workflow what a signed-in user mainly does, step by step
121
121
  Leave out what you do not know. Onboarding still proves everything it takes
122
- from the notes. New notes start a new onboarding; the same notes again do not.
122
+ from the notes. The app is onboarded once: notes written before that feed its
123
+ onboarding (an onboarding that stopped on a question starts again with them);
124
+ after it, new notes start no onboarding.
123
125
 
124
126
  Every run also plans telemetry for the app: --app names it when the repository
125
127
  has several (init never picks one), --origin its production origins and
@@ -180,7 +182,8 @@ your crawl of that exact capture: the same code and the same change title (the
180
182
  title tells the crawl's judge what the change is). When there is none, or the
181
183
  one it finds was cancelled, stopped before finishing or is being cancelled, it
182
184
  submits the capture (the service starts the crawl, or runs the stopped one
183
- again), says so, and follows that crawl.
185
+ again), says so, and follows that crawl. When the stop hook already built and
186
+ froze this change, the crawl starts from that instead of building it again.
184
187
 
185
188
  The coding agent that made the change can say what it was asked to do (--intent:
186
189
  the task in the user's words, not a description of its code) and what to try (--idea, as many as it likes, or --ideas-file). Each idea is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.27.1",
3
+ "version": "0.27.2",
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": {