@haystackeditor/cli 0.20.0 → 0.21.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.
@@ -262,7 +262,9 @@ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
262
262
  }
263
263
  export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS,
264
264
  /** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
265
- budgetMs) {
265
+ budgetMs,
266
+ /** Amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' is `haystack verify`. */
267
+ mode = 'crawl') {
266
268
  // Capture this before git and compression work so delivery latency cannot
267
269
  // make an older stop look newer than a later one.
268
270
  const observedAt = Date.now();
@@ -290,7 +292,7 @@ budgetMs) {
290
292
  cacheKey,
291
293
  patchGzBase64: patchGzip.toString('base64'),
292
294
  patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
293
- ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }) } }),
295
+ ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode } }),
294
296
  },
295
297
  };
296
298
  }
@@ -333,7 +335,7 @@ function readWalkerStatus(value) {
333
335
  }
334
336
  throw new PrecomputeFailure('The precompute service returned an invalid walker status.');
335
337
  }
336
- const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', 'superseded', 'enqueue-failed'];
338
+ const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', 'enqueue-failed'];
337
339
  const isOnboardRunId = (value) => typeof value === 'string' && /^ob_[0-9a-f]{48}$/.test(value);
338
340
  function readCrawlHookStatus(value) {
339
341
  if (value && typeof value === 'object' && !Array.isArray(value)) {
@@ -365,7 +367,6 @@ function readCrawlHookStatus(value) {
365
367
  export function crawlUnavailableText(reason) {
366
368
  switch (reason) {
367
369
  case 'runner-unavailable': return 'no machine that can crawl this repository is available right now';
368
- case 'superseded': return 'a newer stop in this repository came first';
369
370
  case 'enqueue-failed': return 'the service could not queue it';
370
371
  }
371
372
  }
@@ -455,14 +456,16 @@ function printResult(result) {
455
456
  : 'Walker unavailable: the enqueue request failed.');
456
457
  else
457
458
  console.log('Walker status was not reported by the service.');
459
+ // Amendment 12: this capture builds and freezes the app only; `haystack verify` crawls it.
458
460
  if (crawl?.status === 'queued')
459
- console.log(`Crawl queued (${crawl.runId}).`);
461
+ console.log(`Building and freezing the app for this change (${crawl.runId}); run \`haystack verify\` to check it.`);
460
462
  else if (crawl?.status === 'existing')
461
- console.log(`A matching crawl already exists (${crawl.runId}).`);
463
+ console.log(`This change is already built or being built (${crawl.runId}); run \`haystack verify\` to check it.`);
462
464
  else if (crawl?.status === 'unavailable')
463
465
  console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
464
466
  else if (crawl?.status === 'onboarding') {
465
- console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); the crawl runs when it is ready. Run \`haystack verify\` to follow it.`);
467
+ console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); this change is built and frozen when it is ready.`
468
+ + ' Run `haystack verify` to check it.');
466
469
  }
467
470
  else if (crawl?.status === 'onboarding-blocked')
468
471
  console.log(blockLines(crawl.block).join('\n'));
@@ -485,7 +488,9 @@ function configuredCrawlMinutes(gitRoot) {
485
488
  }
486
489
  return minutes;
487
490
  }
488
- export async function captureCheckout(deadline, repository,
491
+ export async function captureCheckout(deadline,
492
+ /** Amendment 12: the turn-end hook and `verify precompute` ask to prepare (build and freeze); `haystack verify` to crawl. */
493
+ mode, repository,
489
494
  /** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
490
495
  minutes) {
491
496
  const gitRoot = findGitRoot();
@@ -495,7 +500,7 @@ minutes) {
495
500
  const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
496
501
  const target = repository ?? resolveOriginRepository(gitRoot);
497
502
  const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
498
- const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000);
503
+ const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode);
499
504
  return { metadataDirectory, derivation };
500
505
  }
501
506
  /** The explicit (non-hook) submission of a capture: one synchronous
@@ -533,7 +538,8 @@ export async function verifyPrecomputeCommand(options) {
533
538
  const deadline = startedAt + totalWallMs;
534
539
  let outcome = 'error';
535
540
  try {
536
- const capture = await captureCheckout(deadline);
541
+ // The hook and an explicit precompute build and freeze the change ahead of `haystack verify`; they never crawl it.
542
+ const capture = await captureCheckout(deadline, 'prepare');
537
543
  if (options.hook) {
538
544
  remainingMs(deadline);
539
545
  await startPrecomputeDelivery(capture.metadataDirectory, capture.derivation.request);
@@ -36,8 +36,8 @@ const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
36
36
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
37
37
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
38
38
  const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
39
- const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'incomplete', 'cancelled'];
40
- const TERMINAL = new Set(['completed', 'incomplete', 'cancelled']);
39
+ const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'prepared', 'incomplete', 'cancelled'];
40
+ const TERMINAL = new Set(['completed', 'prepared', 'incomplete', 'cancelled']);
41
41
  const STAGES = ['reconcile', 'source', 'worlds', 'blast', 'knobs', 'prepare', 'crawl', 'publish', 'cleanup'];
42
42
  const ERROR_CODES = ['source-mismatch', 'blast-unavailable', 'world-build-failed',
43
43
  'prepare-failed', 'crawl-failed', 'no-capacity', 'stalled', 'lease-lost', 'cancelled'];
@@ -50,6 +50,7 @@ const STATUS_WORDS = {
50
50
  crawling: 'exploring the app',
51
51
  draining: 'finishing the last checks',
52
52
  completed: 'finished',
53
+ prepared: 'built and ready to check',
53
54
  incomplete: 'stopped before finishing',
54
55
  cancelled: 'cancelled',
55
56
  };
@@ -213,7 +214,7 @@ export function parseCrawlView(value, expected) {
213
214
  || typeof value.changeTitle !== 'string' || !isCount(value.observedAt)
214
215
  || !isTimestamp(value.createdAt) || !isTimestamp(value.updatedAt)
215
216
  || (value.supersededBy !== null && (typeof value.supersededBy !== 'string' || !RUN_ID.test(value.supersededBy)))
216
- || typeof value.cancelRequested !== 'boolean')
217
+ || typeof value.cancelRequested !== 'boolean' || (value.mode !== 'prepare' && value.mode !== 'crawl'))
217
218
  invalid('its status');
218
219
  const clocks = value.clocks;
219
220
  if (!isRecord(clocks) || !['startedAt', 'stageStartedAt', 'crawlStartedAt', 'finishedAt']
@@ -333,13 +334,15 @@ function headline(view) {
333
334
  const error = results(view)?.error ?? view.error;
334
335
  return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
335
336
  }
337
+ case 'prepared':
338
+ // Amendment 12: the turn-end hook built and froze this change; `haystack verify` asks for the crawl itself.
339
+ return 'Your change is built and ready to check, but was not crawled yet. Run `haystack verify` to check it.';
336
340
  case 'cancelled':
337
- return `The crawl was cancelled${view.supersededBy ? ': a newer stop in this repository replaced it' : ''}.${found}`;
341
+ return `The crawl was cancelled.${found}`;
338
342
  default: {
339
343
  const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
340
344
  if (view.cancelRequested) {
341
- return `Being cancelled${view.supersededBy ? ' (a newer stop in this repository replaced it)' : ''}: `
342
- + `${currentStep(view)}.${so}`;
345
+ return `Being cancelled: ${currentStep(view)}.${so}`;
343
346
  }
344
347
  return `Still running: ${currentStep(view)}.${so}`;
345
348
  }
@@ -499,9 +502,9 @@ async function waitForCrawl(first, identity, token) {
499
502
  // The followed crawl's own read is held, so only a change to it wakes the wait (Astra review: a capture read could sit up to
500
503
  // 25 s on another title's crawl of the same code).
501
504
  let next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
502
- // A newer stop of the same change supersedes this crawl: once it is superseded or being cancelled, the capture's newest crawl
503
- // is read at once (not held) and followed if it is this same change.
504
- if (next.supersededBy !== null || next.cancelRequested) {
505
+ // Once this crawl is being cancelled, the capture's newest crawl is read at once (not held) and followed if it is this same
506
+ // change (amendment 12: no crawl replaces another, but one can be cancelled and asked for again).
507
+ if (next.cancelRequested) {
505
508
  const newest = await readCrawl(identity, token);
506
509
  if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
507
510
  next = newest;
@@ -530,7 +533,10 @@ function staleCrawl(found, identity) {
530
533
  return 'other-time';
531
534
  if (found.status === 'cancelled' || found.status === 'incomplete')
532
535
  return found.status;
533
- return !TERMINAL.has(found.status) && found.cancelRequested ? 'cancelling' : null;
536
+ if (!TERMINAL.has(found.status) && found.cancelRequested)
537
+ return 'cancelling';
538
+ // A `prepared` run is asked for its crawl whatever its mode (a cancel can leave a widened one prepared).
539
+ return found.mode === 'prepare' || found.status === 'prepared' ? 'prepare-only' : null;
534
540
  }
535
541
  function staleWords(found, stale) {
536
542
  switch (stale) {
@@ -539,6 +545,9 @@ function staleWords(found, stale) {
539
545
  case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
540
546
  case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
541
547
  case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
548
+ case 'prepare-only': return found.status === 'prepared'
549
+ ? `Your change was built and frozen ahead of time (${found.runId})`
550
+ : `Your change is being built and frozen ahead of time (${found.runId})`;
542
551
  }
543
552
  }
544
553
  /** Submits the capture through the explicit precompute submission when no crawl
@@ -584,7 +593,8 @@ async function submitCapture(capture, token, found) {
584
593
  : `The service already had a crawl for this change (${crawl.runId}).` };
585
594
  }
586
595
  if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
587
- return { runId: crawl.runId, onboarding: null, line: `${before}; asked the service to run it again.` };
596
+ return { runId: crawl.runId, onboarding: null, line: found.stale === 'prepare-only'
597
+ ? `${before}; checking it now, reusing what was built.` : `${before}; asked the service to run it again.` };
588
598
  }
589
599
  return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
590
600
  ? `${before}; started one for this change (${crawl.runId}).`
@@ -619,7 +629,7 @@ export async function verifyCommand(options) {
619
629
  console.error(line);
620
630
  else
621
631
  console.log(line); };
622
- const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
632
+ const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes));
623
633
  const request = capture.derivation.request;
624
634
  const change = checkedChange(request.baseCommit, request.workCommit);
625
635
  if (!request.crawl) {
package/dist/index.js CHANGED
@@ -153,7 +153,8 @@ repository already keeps working. init shows each change as a diff, then makes
153
153
  it with --yes (or a yes at the prompt):
154
154
  • Claude Code's Stop hook (\`haystack verify precompute --hook\`) in the
155
155
  per-developer .claude/settings.local.json, kept out of commits, when
156
- Claude Code is in use here
156
+ Claude Code is in use here: it builds and freezes the changed app ahead
157
+ of time, so \`haystack verify\` starts from there
157
158
  • a short note in AGENTS.md (and CLAUDE.md when the repository keeps one)
158
159
  telling coding agents to run \`haystack verify\` after a change
159
160
  Then it starts onboarding the app, so it is usually ready by the first
@@ -264,10 +265,9 @@ Exit codes:
264
265
  0 the crawl answered or finished (the bugs it found are in the output), or
265
266
  it is still running and --no-wait was given
266
267
  1 the command failed, or no crawl could be started for this change
267
- 2 the crawl ended without finishing: it stopped early or was cancelled
268
- (a newer stop in the repository replaces an older crawl), or the
269
- machines it used could not be proven shut down; or onboarding stopped
270
- before finishing
268
+ 2 the crawl ended without finishing: it stopped early or was cancelled,
269
+ or the machines it used could not be proven shut down; or onboarding
270
+ stopped before finishing
271
271
  3 onboarding is blocked: the output says what would unblock it
272
272
 
273
273
  The hosted fleet run of pushed commits is \`haystack verify hosted start\`.
@@ -367,7 +367,7 @@ Examples:
367
367
  });
368
368
  verify
369
369
  .command('precompute')
370
- .description('Precompute reusable verification inputs for the current working tree')
370
+ .description('Build and freeze the current working tree\'s app ahead of `haystack verify` (no crawl)')
371
371
  .option('--hook', 'Run silently as a best-effort repository hook')
372
372
  .action(async (options) => {
373
373
  await runPublicCommand(async () => {
@@ -1359,7 +1359,8 @@ hooks
1359
1359
  Configures your coding CLI to run \`haystack triage --hook\` on session start.
1360
1360
  This shows pending PR analysis results when you open a new terminal session.
1361
1361
  Claude Code also runs \`haystack verify precompute --hook\` when a session stops,
1362
- which starts a crawl of the change in the background (see \`haystack verify\`).
1362
+ which builds and freezes the changed app in the background so that
1363
+ \`haystack verify\` starts from there. It never crawls; \`haystack verify\` does.
1363
1364
 
1364
1365
  Claude Code: Native SessionStart and Stop hooks (.claude/settings.local.json;
1365
1366
  --shared writes the committed .claude/settings.json)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.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": {