@haystackeditor/cli 0.22.0 → 0.22.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.
@@ -7,3 +7,7 @@ export const CRAWL_MAX_FINDING_STEPS = 64;
7
7
  /** Amendment 9: what a crawl has found so far (`exploring`) or its answer at the budget (`answered`), before the sealed manifest;
8
8
  * findings carry no images. A read may pass `waitAfter=<updatedAt>` to be held until the crawl changes (at most CRAWL_WAIT_MAX_MS). */
9
9
  export const CRAWL_WAIT_MAX_MS = 25_000;
10
+ /** Amendment 13: the crawl's stand-in coverage, one row per provider, operation, kind and phase with its count in each build
11
+ * (how many times the copy's calls reached the stand-in across the crawl's preparation and all its replays, not user actions). */
12
+ export const CRAWL_MAX_STAND_IN_ROWS = 200;
13
+ export const CRAWL_MAX_STAND_IN_LINES = 20;
@@ -93,7 +93,7 @@ export function parseOnboardingStatus(value, expected) {
93
93
  invalid('its data absences');
94
94
  if (!isStrings(value.versionChoices))
95
95
  invalid('its version choices');
96
- if (!isStrings(value.standIns) || !isStrings(value.unsetProviders))
96
+ if (!isStrings(value.standIns) || !isStrings(value.unsetProviders) || !isStrings(value.standInGaps))
97
97
  invalid('its stand-ins');
98
98
  if (value.block !== null)
99
99
  parseOnboardingBlock(value.block);
@@ -290,11 +290,15 @@ export function formatOnboarding(status) {
290
290
  lines.push('', ...status.dataAbsences.map(absence => chalk.yellow(`Note: ${safe(absence)}`)));
291
291
  if (status.versionChoices.length)
292
292
  lines.push('', ...status.versionChoices.map(choice => chalk.yellow(`Note: ${safe(choice)}`)));
293
- const outside = [...status.standIns, ...status.unsetProviders];
293
+ const outside = [...status.standIns, ...status.unsetProviders, ...standInGapNotes(status.standInGaps)];
294
294
  if (outside.length)
295
295
  lines.push('', ...outside.map(note => chalk.yellow(`Note: ${safe(note)}`)));
296
296
  return lines.join('\n');
297
297
  }
298
+ /** Rule 15(g), amendment 3: a stand-in gap as the sentence every result shows. */
299
+ export function standInGapNotes(gaps) {
300
+ return gaps.map(gap => `A stand-in could not serve ${gap} while onboarding signed in and did the workflow; the crawl may meet it too.`);
301
+ }
298
302
  /** Telemetry: onboarding started, blocked or ready, once per state a command sees. */
299
303
  export function reportOnboardingState(status) {
300
304
  if (status.state === 'onboarding')
@@ -426,7 +426,9 @@ export function normalModeFailure(error) {
426
426
  if (error.status === 429) {
427
427
  return new PrecomputeFailure('Precompute requests are temporarily rate limited. Try again later.');
428
428
  }
429
- return new PrecomputeFailure(`The precompute request failed (HTTP ${error.status}).`);
429
+ // With the server's reason (classifyHttpError's message carries it): a refused capture said only "HTTP 400", and tldraw's
430
+ // could not be told apart from any other refusal (2026-10-02).
431
+ return new PrecomputeFailure(`The precompute request failed: ${error.message}`);
430
432
  }
431
433
  // The deadline fired while the POST was in flight. The server may still
432
434
  // have accepted the job, so say what happened rather than "failed".
@@ -437,26 +439,10 @@ export function normalModeFailure(error) {
437
439
  }
438
440
  function printResult(result) {
439
441
  const { outcome, runId, walker, crawl } = result;
440
- if (outcome === 'enqueued') {
441
- console.log(chalk.green(`Precompute request queued${runId ? ` (${runId})` : ''}.`));
442
- }
443
- else if (outcome === 'cached') {
444
- console.log(chalk.green('Precompute result is already cached.'));
445
- }
446
- else if (outcome === 'existing') {
447
- console.log(chalk.green('A matching precompute request already exists.'));
448
- }
449
- if (walker?.status === 'queued')
450
- console.log(`Walker queued (${walker.runId}).`);
451
- else if (walker?.status === 'existing')
452
- console.log(`A matching walker run already exists (${walker.runId}).`);
453
- else if (walker?.status === 'unavailable')
454
- console.log(walker.reason === 'runner-unavailable'
455
- ? 'Walker unavailable: no runner is available for this repository.'
456
- : 'Walker unavailable: the enqueue request failed.');
457
- else
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.
442
+ // What the change's check depends on comes first and alone (amendment 12: this capture builds and freezes the app;
443
+ // `haystack verify` crawls it). A coding agent reads this output: the background requests below follow it, marked as
444
+ // background, so neither their run ids nor an "unavailable" walker reads as the check failing (2026-10-02: a run id taken
445
+ // from the first line was the front-half request's, and "Walker unavailable" looked like an error).
460
446
  if (crawl?.status === 'queued')
461
447
  console.log(`Building and freezing the app for this change (${crawl.runId}); run \`haystack verify\` to check it.`);
462
448
  else if (crawl?.status === 'existing')
@@ -471,6 +457,15 @@ function printResult(result) {
471
457
  console.log(blockLines(crawl.block).join('\n'));
472
458
  else
473
459
  console.log('Crawl status was not reported by the service.');
460
+ const front = outcome === 'enqueued' ? `front-half precompute queued${runId ? ` (${runId})` : ''}`
461
+ : outcome === 'cached' ? 'front-half precompute already cached'
462
+ : outcome === 'existing' ? 'front-half precompute already requested' : null;
463
+ const walk = walker?.status === 'queued' ? `repository walk queued (${walker.runId})`
464
+ : walker?.status === 'existing' ? `repository walk already running (${walker.runId})`
465
+ : walker?.status === 'unavailable' ? (walker.reason === 'runner-unavailable'
466
+ ? 'repository walk not started (no walker runner for this repository)' : 'repository walk not started (its request failed)')
467
+ : 'repository walk status not reported';
468
+ console.log(chalk.dim(`Background, not needed by the check above: ${[front, walk].filter(Boolean).join('; ')}.`));
474
469
  }
475
470
  /** The hook, an explicit precompute and `haystack verify` all capture here.
476
471
  * The repository defaults to origin's, as the hook's does. */
@@ -28,9 +28,9 @@ import { findGitRoot } from '../utils/hooks.js';
28
28
  import { resolveAuthContext } from '../utils/auth.js';
29
29
  import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
30
30
  import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
31
- import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
31
+ import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
32
32
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
33
- import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, waitForOnboarding } from './verify-onboarding.js';
33
+ import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, standInGapNotes, waitForOnboarding } from './verify-onboarding.js';
34
34
  const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
35
35
  /** A held read (waitAfter): the service's hold, then the time any read gets. */
36
36
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
@@ -137,6 +137,16 @@ function checkFinding(value) {
137
137
  || (value.images.old !== null && typeof value.images.old !== 'string')
138
138
  || (value.images.new !== null && typeof value.images.new !== 'string'))
139
139
  invalid('a finding');
140
+ // Amendment 13: present only when the crawl's worlds ran stand-ins for outside services.
141
+ if (value.standIns !== undefined && (!isRecord(value.standIns) || !isStrings(value.standIns.old, CRAWL_MAX_STAND_IN_LINES)
142
+ || !isStrings(value.standIns.new, CRAWL_MAX_STAND_IN_LINES)))
143
+ invalid('the outside services on a finding\'s path');
144
+ }
145
+ /** Amendment 13: one row of the crawl's stand-in coverage. */
146
+ function isStandInRow(value) {
147
+ return isRecord(value) && typeof value.provider === 'string' && typeof value.operation === 'string'
148
+ && (value.kind === 'unsupported' || value.kind === 'mutates') && isMember(value.phase, ['build', 'boot', 'run'])
149
+ && isCount(value.old) && isCount(value.new);
140
150
  }
141
151
  function checkSpot(value) {
142
152
  if (!isRecord(value) || typeof value.id !== 'string' || typeof value.file !== 'string'
@@ -181,6 +191,11 @@ function checkResults(value, view, what) {
181
191
  if (blast !== null && (!isRecord(blast) || !isCount(blast.functions) || !isCount(blast.ran)
182
192
  || !isCount(blast.unreached) || !isStrings(blast.nearestUnreached)))
183
193
  invalid('its affected functions');
194
+ const standIns = value.standIns;
195
+ if (standIns !== undefined && (!isRecord(standIns) || !Array.isArray(standIns.coverage)
196
+ || standIns.coverage.length > CRAWL_MAX_STAND_IN_ROWS || !standIns.coverage.every(isStandInRow)
197
+ || !isCount(standIns.omittedRows) || !isStrings(standIns.gaps, CRAWL_MAX_STAND_IN_ROWS)))
198
+ invalid('its outside-service coverage');
184
199
  if (value.error !== null)
185
200
  checkError(value.error, 'its error');
186
201
  }
@@ -369,6 +384,12 @@ function findingLines(finding, position) {
369
384
  const lines = [` ${position}. ${label}: ${finding.why === null ? 'no reason was recorded.' : safe(finding.why)}`,
370
385
  ` Starting from ${safe(finding.start)}:`];
371
386
  finding.steps.forEach((step, index) => lines.push(` ${index + 1}. ${safe(step)}`));
387
+ // Amendment 13: the outside-service calls on its path, answered by Haystack stand-ins, as the judge read them.
388
+ const outside = finding.standIns;
389
+ if (outside && (outside.old.length > 0 || outside.new.length > 0)) {
390
+ const side = (calls) => (calls.length ? calls.map(safe).join('; ') : 'none');
391
+ lines.push(chalk.dim(' Outside services on this path, answered by Haystack stand-ins ("unsupported": one it could not serve):'), chalk.dim(` without your change: ${side(outside.old)}`), chalk.dim(` with your change: ${side(outside.new)}`));
392
+ }
372
393
  return lines;
373
394
  }
374
395
  export function formatCrawl(view) {
@@ -418,6 +439,13 @@ export function formatCrawl(view) {
418
439
  const blast = published.reach.blast;
419
440
  if (blast !== null)
420
441
  lines.push(` ${blast.ran} of ${plural(blast.functions, 'affected function')} ran.`);
442
+ // Amendment 13: calls to outside services the stand-ins could not serve: the copy's missing coverage, never a finding.
443
+ const gaps = published.standIns?.gaps ?? [];
444
+ if (gaps.length > 0) {
445
+ lines.push('', chalk.bold('Outside services the copy could not imitate'), ' Haystack stand-ins answer these services in the copy, but could not serve these calls, so paths through them', ' did not behave as they would in production (missing coverage, not bugs in your change):');
446
+ for (const gap of gaps)
447
+ lines.push(` ${safe(gap)}`);
448
+ }
421
449
  // Only a finished crawl has proven its cleanup: an answered one is still shutting its machines down (Bugbot).
422
450
  if (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0) {
423
451
  lines.push('', chalk.yellow(` ${plural(view.totals.cleanupUnproven, 'machine')} it used could not be proven shut down.`));
@@ -710,7 +738,7 @@ export async function verifyCommand(options) {
710
738
  else {
711
739
  console.log(formatCrawl(view));
712
740
  const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? []), ...(onboarding?.standIns ?? []),
713
- ...(onboarding?.unsetProviders ?? [])];
741
+ ...(onboarding?.unsetProviders ?? []), ...standInGapNotes(onboarding?.standInGaps ?? [])];
714
742
  if (notes.length)
715
743
  console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
716
744
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.22.0",
3
+ "version": "0.22.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": {
@@ -288,7 +288,7 @@
288
288
  "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
289
289
  "count": { "type": "integer", "minimum": 0 },
290
290
  "timestamp": { "type": ["string", "null"], "format": "date-time" },
291
- "status": { "enum": ["queued", "preparing", "crawling", "draining", "completed", "incomplete", "cancelled"] },
291
+ "status": { "enum": ["queued", "preparing", "crawling", "draining", "completed", "prepared", "incomplete", "cancelled"] },
292
292
  "stage": { "enum": ["reconcile", "source", "worlds", "blast", "knobs", "prepare", "crawl", "publish", "cleanup"] },
293
293
  "spotStage": { "enum": ["resolved", "loaded", "exposed", "activated", "pressed", "observed", "confirmed", "judged"] },
294
294
  "reuseValue": { "enum": ["hit", "miss", null] },
@@ -334,9 +334,31 @@
334
334
  "old": { "type": ["string", "null"] },
335
335
  "new": { "type": ["string", "null"] }
336
336
  }
337
+ },
338
+ "standIns": {
339
+ "description": "CRAWL-V1 amendment 13, only when the app's copies answer outside services (sign-in, mail, payments...) with Haystack stand-ins: the calls to them on the path this finding was judged on, per build, one line per distinct call ('<unsupported|mutates> <provider>: <operation>', the phase added in parentheses when not run). 'unsupported': the stand-in could not serve the call, so the copy did not behave as production would there.",
340
+ "type": "object",
341
+ "required": ["old", "new"],
342
+ "properties": {
343
+ "old": { "type": "array", "maxItems": 20, "items": { "type": "string" } },
344
+ "new": { "type": "array", "maxItems": 20, "items": { "type": "string" } }
345
+ }
337
346
  }
338
347
  }
339
348
  },
349
+ "standInRow": {
350
+ "description": "CRAWL-V1 amendment 13: one distinct outside-service call the crawl's stand-ins logged, with its count in each build: how many times the copy's calls reached the stand-in across the crawl's preparation and all its replays (double-checks and re-walks included), not how many user actions did.",
351
+ "type": "object",
352
+ "required": ["provider", "operation", "kind", "phase", "old", "new"],
353
+ "properties": {
354
+ "provider": { "type": "string" },
355
+ "operation": { "type": "string" },
356
+ "kind": { "enum": ["unsupported", "mutates"] },
357
+ "phase": { "enum": ["build", "boot", "run"] },
358
+ "old": { "$ref": "#/$defs/count" },
359
+ "new": { "$ref": "#/$defs/count" }
360
+ }
361
+ },
340
362
  "spot": {
341
363
  "description": "One changed spot (one hunk of a changed file) and how far the crawl got with it: the change's product blast radius.",
342
364
  "type": "object",
@@ -420,15 +442,25 @@
420
442
  }
421
443
  }
422
444
  },
445
+ "standIns": {
446
+ "description": "CRAWL-V1 amendment 13, only when the app's copies answer outside services with Haystack stand-ins: every distinct call the crawl's stand-ins logged, both builds alike (at most 200 rows, the rest counted in omittedRows), and each call a stand-in could not serve as a gap line ('<provider>: <operation> (only without your change|only with your change|with and without your change)'). Gaps are missing coverage of the copy, never findings.",
447
+ "type": "object",
448
+ "required": ["coverage", "omittedRows", "gaps"],
449
+ "properties": {
450
+ "coverage": { "type": "array", "maxItems": 200, "items": { "$ref": "#/$defs/standInRow" } },
451
+ "omittedRows": { "$ref": "#/$defs/count" },
452
+ "gaps": { "type": "array", "maxItems": 200, "items": { "type": "string" } }
453
+ }
454
+ },
423
455
  "notFinishedInTime": { "$ref": "#/$defs/count" },
424
456
  "error": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/error" }] }
425
457
  }
426
458
  },
427
459
  "manifest": {
428
- "description": "Present once the crawl is terminal and published.",
460
+ "description": "Present once the crawl is terminal and published. 'prepared' (CRAWL-V1 amendment 12): the change was built and frozen by the turn-end hook and not crawled.",
429
461
  "allOf": [{ "$ref": "#/$defs/results" }],
430
462
  "properties": {
431
- "status": { "enum": ["completed", "incomplete", "cancelled"] }
463
+ "status": { "enum": ["completed", "prepared", "incomplete", "cancelled"] }
432
464
  }
433
465
  },
434
466
  "answer": {
@@ -460,6 +492,7 @@
460
492
  "updatedAt": { "type": "string", "format": "date-time" },
461
493
  "supersededBy": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/runId" }] },
462
494
  "cancelRequested": { "type": "boolean" },
495
+ "mode": { "enum": ["prepare", "crawl"], "description": "CRAWL-V1 amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' checks the change." },
463
496
  "status": { "$ref": "#/$defs/status" },
464
497
  "stage": { "$ref": "#/$defs/stage" },
465
498
  "clocks": {