@haystackeditor/cli 0.22.1 → 0.23.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.
|
@@ -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')
|
|
@@ -8,7 +8,7 @@ import chalk from 'chalk';
|
|
|
8
8
|
import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
|
|
9
9
|
import { findGitRoot, parseRemoteUrl, } from '../utils/git.js';
|
|
10
10
|
import { resolveVerifyBaseSha } from '../utils/verify-base.js';
|
|
11
|
-
import { HaystackApiError, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
|
|
11
|
+
import { HaystackApiError, haystackFetch, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
|
|
12
12
|
import { trackPrecomputeEvent } from '../utils/telemetry.js';
|
|
13
13
|
import { findSecretsInPatch, isSecretBearingPath } from '../utils/secret-paths.js';
|
|
14
14
|
import { startPrecomputeDelivery } from './precompute-delivery.js';
|
|
@@ -313,13 +313,45 @@ function precomputeResult(response) {
|
|
|
313
313
|
return { outcome: 'existing', ...reported };
|
|
314
314
|
throw new PrecomputeFailure('The precompute service returned an invalid response.');
|
|
315
315
|
}
|
|
316
|
+
/** The captured change as the service takes it (CRAWL-V1 amendment 14): uploaded once, content-addressed by its sha256 in
|
|
317
|
+
* the repository's scope (a HEAD finds an earlier upload; a PUT only when it is missing), and named in the front-half
|
|
318
|
+
* request by digest and size instead of carried inline. Nothing on the way holds a size limit of its own: tldraw's 13.8 MB
|
|
319
|
+
* capture was refused at the 8 MiB inline bound (2026-10-02). `force` uploads even when the HEAD found it (a 409 says the
|
|
320
|
+
* service no longer has it). */
|
|
321
|
+
async function uploadSourcePatch(request, token, signal, force = false) {
|
|
322
|
+
const gzipped = Buffer.from(request.patchGzBase64, 'base64');
|
|
323
|
+
const path = `/api/agent/cloud-verifier/source-patches/${request.patchSha256}`
|
|
324
|
+
+ `?owner=${encodeURIComponent(request.owner)}&repository=${encodeURIComponent(request.repository)}`;
|
|
325
|
+
const found = force ? null : await haystackFetch(path, token, { method: 'HEAD', signal }, [404]);
|
|
326
|
+
if (found === null || found.status === 404) {
|
|
327
|
+
await haystackFetch(path, token, { method: 'PUT', headers: { 'Content-Type': 'application/gzip' }, body: gzipped, signal });
|
|
328
|
+
}
|
|
329
|
+
return gzipped.byteLength;
|
|
330
|
+
}
|
|
331
|
+
/** One front-half submission of a captured request: the change uploaded (uploadSourcePatch), then the request naming it. A
|
|
332
|
+
* 409 (the service has no such upload: it expired between the HEAD and the POST) uploads it again and submits once more. */
|
|
333
|
+
async function submitCapture(request, token, signal) {
|
|
334
|
+
const { patchGzBase64: _inline, ...named } = request;
|
|
335
|
+
const submit = async (force) => {
|
|
336
|
+
const patchBytes = await uploadSourcePatch(request, token, signal, force);
|
|
337
|
+
return haystackJson('/api/agent/cloud-verifier/front-half', token, { method: 'POST',
|
|
338
|
+
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...named, patchBytes }), signal });
|
|
339
|
+
};
|
|
340
|
+
try {
|
|
341
|
+
return await submit(false);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
if (!(error instanceof HaystackApiError && error.status === 409))
|
|
345
|
+
throw error;
|
|
346
|
+
return submit(true);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
316
349
|
/** One authenticated submission of the already captured request. No source reads or retries. */
|
|
317
350
|
export const deliverPrecomputeRequest = async (request, signal) => {
|
|
318
351
|
signal.throwIfAborted();
|
|
319
352
|
const auth = await resolveAuthContext({ owner: request.owner, repo: request.repository });
|
|
320
353
|
signal.throwIfAborted();
|
|
321
|
-
return precomputeResult(await
|
|
322
|
-
body: JSON.stringify(request), signal }));
|
|
354
|
+
return precomputeResult(await submitCapture(request, auth.token, signal));
|
|
323
355
|
};
|
|
324
356
|
function readWalkerStatus(value) {
|
|
325
357
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
@@ -426,7 +458,9 @@ export function normalModeFailure(error) {
|
|
|
426
458
|
if (error.status === 429) {
|
|
427
459
|
return new PrecomputeFailure('Precompute requests are temporarily rate limited. Try again later.');
|
|
428
460
|
}
|
|
429
|
-
|
|
461
|
+
// With the server's reason (classifyHttpError's message carries it): a refused capture said only "HTTP 400", and tldraw's
|
|
462
|
+
// could not be told apart from any other refusal (2026-10-02).
|
|
463
|
+
return new PrecomputeFailure(`The precompute request failed: ${error.message}`);
|
|
430
464
|
}
|
|
431
465
|
// The deadline fired while the POST was in flight. The server may still
|
|
432
466
|
// have accepted the job, so say what happened rather than "failed".
|
|
@@ -504,12 +538,7 @@ export async function postPrecomputeCapture(capture, token, deadline) {
|
|
|
504
538
|
const fetchTimer = setTimeout(() => controller.abort(), remainingMs(deadline));
|
|
505
539
|
let response;
|
|
506
540
|
try {
|
|
507
|
-
response = await
|
|
508
|
-
method: 'POST',
|
|
509
|
-
headers: { 'Content-Type': 'application/json' },
|
|
510
|
-
body: JSON.stringify(capture.derivation.request),
|
|
511
|
-
signal: controller.signal,
|
|
512
|
-
});
|
|
541
|
+
response = await submitCapture(capture.derivation.request, token, controller.signal);
|
|
513
542
|
}
|
|
514
543
|
finally {
|
|
515
544
|
clearTimeout(fetchTimer);
|
package/dist/commands/verify.js
CHANGED
|
@@ -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
|
}
|
|
@@ -183,6 +183,11 @@ export async function classifyHttpError(response, context) {
|
|
|
183
183
|
return new HaystackApiError(response.status, `${context}: unexpected response (HTTP ${response.status})${suffix}.`);
|
|
184
184
|
}
|
|
185
185
|
export async function haystackJson(path, token, init = {}, acceptedStatuses = []) {
|
|
186
|
+
const response = await haystackFetch(path, token, init, acceptedStatuses);
|
|
187
|
+
return response.json();
|
|
188
|
+
}
|
|
189
|
+
/** An authenticated request whose response is not JSON (a HEAD, an upload): the response itself, or the classified error. */
|
|
190
|
+
export async function haystackFetch(path, token, init = {}, acceptedStatuses = []) {
|
|
186
191
|
const response = await fetch(haystackApiUrl(path), {
|
|
187
192
|
...init,
|
|
188
193
|
headers: headers(token, init.headers),
|
|
@@ -190,7 +195,7 @@ export async function haystackJson(path, token, init = {}, acceptedStatuses = []
|
|
|
190
195
|
if (!response.ok && !acceptedStatuses.includes(response.status)) {
|
|
191
196
|
throw await classifyHttpError(response, `Haystack API ${path}`);
|
|
192
197
|
}
|
|
193
|
-
return response
|
|
198
|
+
return response;
|
|
194
199
|
}
|
|
195
200
|
export async function fetchCliIdentity(token) {
|
|
196
201
|
return haystackJson('/api/cli/me', token);
|
package/package.json
CHANGED
package/schemas/verify.v1.json
CHANGED
|
@@ -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": {
|