@haystackeditor/cli 0.28.1 → 0.30.1

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
@@ -43,9 +43,12 @@ Most users of this CLI are coding agents. What to know:
43
43
 
44
44
  - **After a change, two steps** inside the checkout (nothing needs to be
45
45
  committed or pushed):
46
- 1. `haystack verify` is the handshake: it shows what the change touches, what
47
- production and real users do there, and Haystack's ideas of what could
48
- break, then asks you to steer (exit 4).
46
+ 1. `haystack verify` is the handshake: it shows what the change touches
47
+ (first its flows, the behaviors it changes), what production and real
48
+ users do there, and Haystack's ideas of what could break, then asks you to
49
+ steer (exit 4). `haystack verify flow <n>` drills into one flow: its code,
50
+ the pages it shows on with their real use, and what its code saw in
51
+ production.
49
52
  2. `haystack verify --intent "<the task, in the user's words>" --idea "<as you
50
53
  would tell a tester>"` explores the app with your steering: keep the ideas
51
54
  worth trying, add your own (repeat `--idea`); yours are explored first.
@@ -115,7 +118,9 @@ answered or finished (the bugs
115
118
  it found are in the output) or is still running under `--no-wait`; 2 when it ended
116
119
  without finishing (stopped early, or cancelled) or the machines it used could
117
120
  not be proven shut down;
118
- 1 when the command failed or no crawl could be started.
121
+ 1 when the command failed or no crawl could be started; 3 when onboarding is
122
+ blocked; 4 for the handshake (run it again with `--intent`); 5 when the app
123
+ copy is broken and the change could not be tested.
119
124
 
120
125
  **The stop hook.** `haystack init` installs a Claude Code Stop hook that runs
121
126
  `haystack verify precompute --hook` whenever an agent turn stops. It captures
@@ -28,7 +28,7 @@ function captureApplication(gitRoot, app) {
28
28
  return { unreadable: `${file} names no valid applicationId` };
29
29
  return { applicationId: config.applicationId };
30
30
  }
31
- async function readApp(gitRoot, app, repository, token, files, now) {
31
+ async function readAppWindow(gitRoot, app, repository, token) {
32
32
  const application = captureApplication(gitRoot, app);
33
33
  if ('unreadable' in application)
34
34
  return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
@@ -43,15 +43,14 @@ async function readApp(gitRoot, app, repository, token, files, now) {
43
43
  throw new Error('the capture window answer names another application');
44
44
  if (answer.window === null)
45
45
  return { app, applicationId, state: 'no-data' };
46
- return { app, applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
46
+ return { app, applicationId, state: 'ready', window: answer.window };
47
47
  }
48
48
  catch (error) {
49
49
  return { app, applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
50
50
  }
51
51
  }
52
- /** The handshake capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
53
- * files and the changed files. */
54
- export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
52
+ /** Each app's capture window, one per app with a record, or null for a checkout with none. */
53
+ export async function readCaptureWindows(gitRoot, repository, token) {
55
54
  let apps;
56
55
  try {
57
56
  apps = captureConfigDirs(gitRoot, []);
@@ -61,7 +60,18 @@ export async function readPreVerifyCapture(gitRoot, repository, token, files, no
61
60
  }
62
61
  if (apps.length === 0)
63
62
  return null;
64
- return Promise.all(apps.map(app => readApp(gitRoot, app, repository, token, files, now)));
63
+ return Promise.all(apps.map(app => readAppWindow(gitRoot, app, repository, token)));
64
+ }
65
+ /** The handshake capture sections from the apps' windows: the routes `files` serve (the brief's function files and the changed
66
+ * files), with their share of captured sessions. */
67
+ export function preVerifyCapture(windows, files, now) {
68
+ return windows === null ? null : windows.map(app => app.state === 'ready'
69
+ ? { app: app.app, applicationId: app.applicationId, state: 'ready', window: captureChangedRoutes(app.window, files, now) } : app);
70
+ }
71
+ /** The handshake capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
72
+ * files and the changed files. */
73
+ export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
74
+ return preVerifyCapture(await readCaptureWindows(gitRoot, repository, token), files, now);
65
75
  }
66
76
  const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
67
77
  const plain = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); // eslint-disable-line no-control-regex
@@ -15,8 +15,13 @@ export const CRAWL_WAIT_MAX_MS = 25_000;
15
15
  * (how many times the copy's calls reached the stand-in across the crawl's preparation and all its replays, not user actions). */
16
16
  export const CRAWL_MAX_STAND_IN_ROWS = 200;
17
17
  export const CRAWL_MAX_STAND_IN_LINES = 20;
18
+ /** Amendment 22: each health evidence list holds at most this many lines, and the commonest difference lines at most this many. */
19
+ export const CRAWL_HEALTH_MAX_LINES = 40;
20
+ export const CRAWL_HEALTH_MAX_DIFFERENCES = 8;
18
21
  /** Amendment 18: the brief's bounds. */
19
22
  export const CRAWL_BRIEF_MAX_FUNCTIONS = 2000;
20
23
  export const CRAWL_BRIEF_MAX_GROUPS = 2000;
21
24
  /** Amendment 19: rows one brief function keeps per kind of production evidence (the rest counted). */
22
25
  export const CRAWL_BRIEF_MAX_PRODUCTION_ROWS = 100;
26
+ /** Amendment 21: a flow's name, at most this many characters. */
27
+ export const CRAWL_BRIEF_MAX_FLOW_NAME_CHARS = 120;
@@ -11,7 +11,7 @@ export const STATUS_WORDS = {
11
11
  cancelled: 'cancelled',
12
12
  };
13
13
  export const STAGE_WORDS = {
14
- reconcile: 'cleaning up after an earlier try',
14
+ reconcile: 'checking for leftovers of an earlier try',
15
15
  source: 'rebuilding your change',
16
16
  worlds: 'building and starting the app with and without your change',
17
17
  blast: 'waiting for the analysis of what your change touches',
@@ -66,6 +66,38 @@ export function currentStep(view) {
66
66
  export function results(view) {
67
67
  return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
68
68
  }
69
+ /** CRAWL-V1 amendment 22: the copy of the app the crawl ran was itself broken, so it tested nothing; null when it was not judged
70
+ * broken (or the results carry no health). */
71
+ export function brokenCopy(view) {
72
+ const health = results(view)?.health;
73
+ return health !== undefined && health.status === 'app-broken' ? health : null;
74
+ }
75
+ /** What `haystack verify` says first of a broken copy. */
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. */
80
+ export const BROKEN_COPY_STEPS = [
81
+ "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.',
83
+ 'Tell your user this check did not test the change; do not read it as passing.',
84
+ ];
85
+ /** Text that reads as an error, for the lines a broken copy's start screen shows (every one is in --raw). */
86
+ const ERROR_LIKE = /\b(?:errors?|fail(?:ed|s|ure)?|unsupported|exception|denied|refused|unavailable|unauthori[sz]ed|forbidden|invalid|cannot|can't|could not|unable|timed out|timeout|not found|internal server)\b/iu;
87
+ /** What a broken copy's evidence says, as lines: what its start screen shows (its messages, then its texts that read as errors),
88
+ * and the requests that failed and the console errors logged with nobody clicking, each with the builds it was in. */
89
+ export function brokenCopyEvidence(health) {
90
+ const { evidence } = health;
91
+ const shows = [...new Set([...evidence.messages, ...evidence.texts.filter(text => ERROR_LIKE.test(text))])];
92
+ const idle = (kind) => {
93
+ const of = (lines) => lines.filter(line => line.startsWith(`${kind}:`)).map(line => line.slice(kind.length + 1));
94
+ const old = of(evidence.background.old);
95
+ const fresh = of(evidence.background.new);
96
+ return [...new Set([...old, ...fresh])].map(entry => `${entry} (${old.includes(entry) && fresh.includes(entry) ? 'both builds'
97
+ : old.includes(entry) ? 'without your change only' : 'with your change only'})`);
98
+ };
99
+ return { shows, failedRequests: idle('failed'), consoleErrors: idle('errors') };
100
+ }
69
101
  /** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
70
102
  * done; only its shutdown remains) reads as completed. */
71
103
  export function reportedStatus(view) {
@@ -76,9 +108,21 @@ export function headline(view) {
76
108
  const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
77
109
  switch (reportedStatus(view)) {
78
110
  case 'completed':
111
+ // Amendment 22: a broken copy tested nothing, whatever it found.
112
+ if (brokenCopy(view) !== null)
113
+ return BROKEN_COPY_HEADLINE;
79
114
  return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
80
115
  case 'incomplete': {
81
116
  const error = results(view)?.error ?? view.error;
117
+ // Onboarding rule 18 (amendment 38): which world did not start says whose it is, and what Haystack does about it.
118
+ if (error?.code === 'world-build-failed' && view.worlds.base === null) {
119
+ return 'The crawl could not finish: the app did not build or start at the base commit, on its setup. Haystack updates the '
120
+ + 'setup for it and runs this check again (`haystack verify onboarding` shows the update).';
121
+ }
122
+ if (error?.code === 'world-build-failed' && view.worlds.head === null) {
123
+ return 'The crawl could not finish: your change did not build or start, though the base did. Haystack checks whether the change '
124
+ + 'needs something new in the setup and, if it does, adds it and runs this check again (`haystack verify onboarding` shows it).';
125
+ }
82
126
  return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
83
127
  }
84
128
  case 'prepared':
@@ -137,6 +181,9 @@ export function verifyReport(view) {
137
181
  } : null,
138
182
  notFinishedInTime: explored ? published.notFinishedInTime : 0,
139
183
  outsideServiceGaps: explored ? published.standIns?.gaps ?? [] : [],
184
+ health: !explored || published.health === undefined ? null : published.health.status === 'app-broken'
185
+ ? { status: 'app-broken', reason: published.health.reason, ...brokenCopyEvidence(published.health), whatToDo: [...BROKEN_COPY_STEPS] }
186
+ : { status: published.health.status, reason: published.health.reason, shows: [], failedRequests: [], consoleErrors: [], whatToDo: null },
140
187
  error: error ? { code: error.code, message: error.message } : null,
141
188
  machinesNotProvenShutDown: TERMINAL.has(view.status) ? view.totals.cleanupUnproven : 0,
142
189
  };
@@ -145,6 +192,12 @@ export function verifyReport(view) {
145
192
  export function reportMarkdown(report) {
146
193
  const lines = [`# ${report.headline}`, '',
147
194
  `Change: ${report.title}`, `Check ${report.runId} of ${report.repository}: base ${report.baseCommit.slice(0, 12)}, change ${report.workCommit.slice(0, 12)}.`];
195
+ // Amendment 22: a broken copy is said first, with why and what to do, whatever became of the run after it answered (a cancelled
196
+ // or failed run's published health still says it); its findings follow, marked.
197
+ const broken = report.health?.status === 'app-broken' ? report.health : null;
198
+ if (broken !== null) {
199
+ lines.splice(1, 0, '', broken.reason, ...(broken.shows.length ? ['', 'The start screen shows:', ...broken.shows.map(line => `- ${line}`)] : []), ...(broken.failedRequests.length ? ['', 'Requests that failed with nobody clicking:', ...broken.failedRequests.map(line => `- ${line}`)] : []), ...(broken.consoleErrors.length ? ['', 'Console errors with nobody clicking:', ...broken.consoleErrors.map(line => `- ${line}`)] : []), '', '## What to do', ...(broken.whatToDo ?? []).map((step, index) => `${index + 1}. ${step}`));
200
+ }
148
201
  if (report.error && report.status !== 'completed')
149
202
  lines.push(`Details: ${report.error.message}`);
150
203
  if (report.neverRan === null) {
@@ -161,7 +214,9 @@ export function reportMarkdown(report) {
161
214
  if (spot.reachedBy)
162
215
  lines.push(` - reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].join(' > ')}${spot.reachedBy.page ? ` (page ${spot.reachedBy.page})` : ''}`);
163
216
  }
164
- lines.push('', '## What the crawl found');
217
+ lines.push('', broken === null ? '## What the crawl found' : '## What the crawl found in the broken copy');
218
+ if (broken !== null)
219
+ lines.push('The copy was broken, so these differences are the environment\'s, not evidence about the change.');
165
220
  if (report.findings.length === 0)
166
221
  lines.push('No confirmed differences between the app with and without the change.');
167
222
  report.findings.forEach((finding, index) => {
@@ -49,8 +49,9 @@ export const AGENT_NOTE = [
49
49
  'the work done. It runs the app with and without the change, explores it, and reports what broke. It changes nothing,',
50
50
  'so run it without asking.',
51
51
  '',
52
- '1. `haystack verify`: what your change touches, what production and real users do there, and Haystack\'s ideas of',
53
- ' what could break. It then asks you to steer (exit 4).',
52
+ '1. `haystack verify`: what your change touches (first its flows), what production and real users do there, and',
53
+ ' Haystack\'s ideas of what could break. It then asks you to steer (exit 4). `haystack verify flow <n>` drills into',
54
+ ' one flow: its code, the pages it shows on with their real use, and what its code saw in production.',
54
55
  '2. `haystack verify --intent "<what you were asked to do, in the user\'s words>" --idea "<something to try>"`: keep the',
55
56
  ' ideas worth trying, add your own (repeat --idea); yours are explored first.',
56
57
  '',
@@ -0,0 +1,62 @@
1
+ /**
2
+ * CRAWL-V1 amendment 21 in the CLI: the brief's flows, as a coding agent reads them. Akshay (2026-10-07): the system says "this
3
+ * change touches N flows", and the agent drills into a flow: the files and functions it runs through, and what telemetry says
4
+ * about it. `haystack verify` lists the flows first; `haystack verify flow <n>` shows one.
5
+ *
6
+ * Browser data comes from each app's capture window, never from daily rollups (CAPTURE-V1 rule 7a): a flow's pages are the
7
+ * routes whose source is one of its files, and its share is the union of captured sessions that visited any of them
8
+ * (captureChangedRoutes). A flow whose files serve no captured route has "no captured route", never 0%. Shares are of captured
9
+ * sessions, never of users. Journeys are only those the window keeps, with their release; their press and submit keys are
10
+ * release-scoped hints, and a key the window lists as ambiguous is never shown as a resolved control. Server data is the brief's
11
+ * production rows for the flow's functions (shares of observed calls, with their sample sizes).
12
+ */
13
+ import { captureChangedRoutes } from './capture-contract.js';
14
+ /** A flow's files, in the order its functions come (changed ones first). */
15
+ export function flowFiles(brief, flow) {
16
+ return [...new Set(flow.functions.map(index => brief.functions[index].file))];
17
+ }
18
+ function flowCapture(windows, files, now) {
19
+ return windows === null ? null : windows.map(app => app.state === 'ready'
20
+ ? { app: app.app, applicationId: app.applicationId, state: 'ready', routes: captureChangedRoutes(app.window, files, now) } : app);
21
+ }
22
+ /** The handshake's flows, in the brief's order; empty when the brief has none. */
23
+ export function flowSummaries(brief, windows, now) {
24
+ return (brief.flows ?? []).map((flow, at) => {
25
+ const files = flowFiles(brief, flow);
26
+ return { number: at + 1, id: flow.id, name: flow.name, files, functions: flow.functions.length,
27
+ changedFunctions: flow.functions.filter(index => brief.functions[index].direction === 'changed').length,
28
+ capture: flowCapture(windows, files, now) };
29
+ });
30
+ }
31
+ function routesOf(window, changed) {
32
+ const rows = new Map(window.routes.map(row => [row.route, row]));
33
+ const ambiguous = new Set((window.ambiguous ?? []).map(entry => JSON.stringify([entry.route, entry.release, entry.key])));
34
+ const mark = (step, release) => (step.kind === 'press' || step.kind === 'submit') && ambiguous.has(JSON.stringify([step.route, release, step.key])) ? { ...step, ambiguous: true } : step;
35
+ return changed.routes.map(route => {
36
+ const row = rows.get(route.route);
37
+ const journeys = (row?.journeys ?? [])
38
+ .filter(journey => journey.steps.some(step => step.route === route.route))
39
+ .map(journey => ({ release: journey.release, sessions: journey.sessions, steps: journey.steps.map(step => mark(step, journey.release)) }));
40
+ return { route: route.route, match: route.match, source: route.source, sessions: route.sessions, share: route.share,
41
+ devices: [...(row?.devices ?? [])].sort((a, b) => b.sessions - a.sessions), journeys };
42
+ });
43
+ }
44
+ /** One flow in detail; null when the brief has no flow with that number (from 1). */
45
+ export function flowDetail(brief, number, windows, now) {
46
+ const flow = brief.flows?.[number - 1];
47
+ if (flow === undefined)
48
+ return null;
49
+ const files = flowFiles(brief, flow);
50
+ const indexes = new Set(flow.functions);
51
+ const production = brief.production === undefined ? null
52
+ : { ...brief.production, functions: brief.production.functions.filter(row => indexes.has(row.index)) };
53
+ const capture = windows === null ? null : windows.map(app => {
54
+ if (app.state !== 'ready')
55
+ return app;
56
+ const window = captureChangedRoutes(app.window, files, now);
57
+ return { app: app.app, applicationId: app.applicationId, window, routes: routesOf(app.window, window) };
58
+ });
59
+ return { number, id: flow.id, name: flow.name, changeKeys: flow.changeKeys,
60
+ functions: flow.functions.map(index => ({ ...brief.functions[index], index })), production, capture,
61
+ ideas: (brief.planning?.ideas ?? []).filter(idea => idea.groupId === flow.id) };
62
+ }
@@ -113,6 +113,23 @@ export function parseOnboardingStatus(value, expected) {
113
113
  if (ready ? !(typeof value.recordPublishedAt === 'string' && !Number.isNaN(Date.parse(value.recordPublishedAt)))
114
114
  : value.recordPublishedAt !== null)
115
115
  invalid('its record time');
116
+ // Rule 18 (amendment 38): a ready record says what it updated, and the update of it under way, if any.
117
+ const recordUpdate = value.recordUpdate;
118
+ if (!(recordUpdate === null || (ready && isRecord(recordUpdate) && typeof recordUpdate.of === 'string' && SHA256.test(recordUpdate.of)
119
+ && typeof recordUpdate.crawlRunId === 'string' && (recordUpdate.side === 'base' || recordUpdate.side === 'head')
120
+ && typeof recordUpdate.workCommit === 'string' && COMMIT.test(recordUpdate.workCommit)
121
+ && Array.isArray(recordUpdate.changes) && recordUpdate.changes.every(change => isRecord(change) && typeof change.field === 'string'
122
+ && (change.name === null || typeof change.name === 'string') && (change.kind === 'added' || change.kind === 'changed')
123
+ && Array.isArray(change.evidence) && change.evidence.every(id => typeof id === 'string')))))
124
+ invalid('its record update');
125
+ const update = value.update;
126
+ if (!(update === null || (ready && isRecord(update) && typeof update.onboardRunId === 'string' && ONBOARD_RUN_ID.test(update.onboardRunId)
127
+ && (update.side === 'base' || update.side === 'head') && typeof update.crawlRunId === 'string'
128
+ && (update.state === 'updating' || update.state === 'blocked' || update.state === 'stopped')
129
+ && (update.state === 'blocked') === (update.block !== null))))
130
+ invalid('its update');
131
+ if (isRecord(update) && update.block !== null)
132
+ parseOnboardingBlock(update.block);
116
133
  if (value.block !== null)
117
134
  parseOnboardingBlock(value.block);
118
135
  const checkpoint = value.checkpoint;
@@ -313,6 +330,9 @@ export function formatOnboarding(status) {
313
330
  lines.push('', ...stages);
314
331
  if (status.block)
315
332
  lines.push('', ...blockLines(status.block));
333
+ const updates = updateLines(status);
334
+ if (updates.length)
335
+ lines.push('', ...updates);
316
336
  if (status.dataAbsences.length)
317
337
  lines.push('', ...status.dataAbsences.map(absence => chalk.yellow(`Note: ${safe(absence)}`)));
318
338
  if (status.versionChoices.length)
@@ -325,6 +345,37 @@ export function formatOnboarding(status) {
325
345
  lines.push('', ...unreviewed.map(note => chalk.yellow(`Note: ${safe(note)}`)));
326
346
  return lines.join('\n');
327
347
  }
348
+ /** Rule 18 (amendment 38): what the ready setup's last update was, and the update of it under way or ended without one: one
349
+ * line each, and a blocked update's block (what it needs, and what to tell Haystack). */
350
+ export function updateLines(status) {
351
+ const lines = [];
352
+ const failed = (side) => side === 'base' ? 'the app did not start at its base commit' : 'the change did not start';
353
+ if (status.recordUpdate) {
354
+ const changes = status.recordUpdate.changes.map(change => `${change.kind} ${change.field === 'env' ? 'variable' : change.field}`
355
+ + `${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)}`
357
+ + `${changes.length ? `: ${changes.join('; ')}` : ''}.`);
358
+ }
359
+ const update = status.update;
360
+ if (update === null)
361
+ return lines;
362
+ if (update.state === 'updating') {
363
+ lines.push(`Updating the setup (${update.onboardRunId}): check ${update.crawlRunId} found ${failed(update.side)}. The check runs `
364
+ + 'again on the updated setup when it is ready.');
365
+ }
366
+ else if (update.state === 'stopped') {
367
+ lines.push(`The update of the setup for check ${update.crawlRunId} (${update.onboardRunId}) stopped before finishing; the next check `
368
+ + 'that fails the same way starts it again.');
369
+ }
370
+ else {
371
+ lines.push(update.side === 'head'
372
+ ? 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.`));
374
+ if (update.block)
375
+ lines.push(...blockLines(update.block));
376
+ }
377
+ return lines;
378
+ }
328
379
  /** Rule 2 (amendment 34): the repository's one setup, which base it was made at and how long ago, in one sentence. */
329
380
  export function readyLine(status, now = Date.now()) {
330
381
  if (status.recordBaseCommit === null || status.recordPublishedAt === null)