@haystackeditor/cli 0.26.0 → 0.27.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.
@@ -56,7 +56,7 @@ export const AGENT_NOTE = [
56
56
  ' `haystack verify answer <question-id> <choice>`.',
57
57
  '- Read its short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
58
58
  ' `haystack verify --help` explains the output and exit codes.',
59
- '- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
59
+ '- If `haystack` is not installed, run each command as `npx -y -p @haystackeditor/cli@latest haystack <command>`.',
60
60
  '- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
61
61
  ' `haystack feedback "<what happened>"` and keep going: the Haystack team sees it at once.',
62
62
  NOTE_END,
@@ -299,6 +299,33 @@ export function planInit(gitRoot) {
299
299
  const notices = [];
300
300
  return { changes: [...planStopHook(gitRoot, notices), ...planAgentNotes(gitRoot, notices)], notices };
301
301
  }
302
+ /** Makes `changes` all or nothing: one that fails (an install that does not end as planned) restores every file they
303
+ * touched (bytes, not text: a lockfile can be binary), so no edit is left referencing what was never installed. */
304
+ function applyAll(gitRoot, changes, say) {
305
+ const before = new Map();
306
+ for (const planned of changes) {
307
+ for (const path of [planned.target, ...(planned.run?.touches ?? [])]) {
308
+ if (!before.has(path))
309
+ before.set(path, existsSync(path) ? readFileSync(path) : null);
310
+ }
311
+ }
312
+ try {
313
+ for (const planned of changes) {
314
+ applyChange(gitRoot, planned);
315
+ say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : planned.action === 'delete' ? 'Deleted' : 'Updated'} ${planned.path}`));
316
+ }
317
+ }
318
+ catch (error) {
319
+ for (const [path, content] of before) {
320
+ if (content !== null)
321
+ writeFileSync(path, content);
322
+ else if (existsSync(path))
323
+ unlinkSync(path);
324
+ }
325
+ throw error;
326
+ }
327
+ }
328
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
302
329
  function applyChange(gitRoot, planned) {
303
330
  const inside = relative(gitRoot, planned.target);
304
331
  if (!inside.startsWith('..') && !isAbsolute(inside))
@@ -362,19 +389,35 @@ export async function initCommand(options) {
362
389
  // Read before anything else, so notes that cannot be used stop init with nothing done.
363
390
  const notes = options.notes === undefined ? null : readNotes(options.notes);
364
391
  const plan = planInit(gitRoot);
365
- // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it.
392
+ // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it. It never fails
393
+ // init: whatever goes wrong with it (a file it cannot read, a package manager that fails, Haystack not answering) puts its
394
+ // own files back and leaves it manual with what happened, and the rest of init goes on.
366
395
  const telemetryModule = await import('./init-telemetry.js');
367
396
  const telemetryOptions = { app: options.app, origins: options.origin ?? [], consent: options.consent, urlRewrites: options.urlRewrites };
368
- let telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
397
+ let telemetryFailure = null;
398
+ let telemetryPlan = telemetryModule.emptyTelemetryPlan();
399
+ try {
400
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
401
+ }
402
+ catch (error) {
403
+ telemetryFailure = errorText(error);
404
+ }
369
405
  const telemetryFrom = plan.changes.length;
370
406
  plan.changes.push(...telemetryPlan.changes);
371
407
  plan.notices.push(...telemetryPlan.notices);
372
408
  let notesWritten = null;
373
409
  let telemetry = null;
410
+ /** Telemetry did not happen: its changes leave the plan (none of them was made), and the report says why. */
411
+ const telemetryFailed = (error) => {
412
+ telemetryFailure = errorText(error);
413
+ plan.changes.splice(telemetryFrom);
414
+ say(chalk.yellow(`Telemetry was not set up: ${telemetryFailure} The rest of init goes on; the telemetry report says what to do.`));
415
+ };
374
416
  // Before the apply (a preview, or no login), the report is the preview: what telemetry will do, `received` unchecked (null).
375
- const report = () => telemetry ?? telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
417
+ const report = () => (telemetryFailure !== null ? telemetryModule.failedTelemetry(telemetryPlan, telemetryFailure)
418
+ : telemetry ?? telemetryModule.previewTelemetry(telemetryPlan, repository, notes));
376
419
  const showPreview = () => {
377
- const preview = telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
420
+ const preview = report();
378
421
  for (const line of telemetryModule.formatTelemetry(preview.telemetry, preview.steps))
379
422
  say(line);
380
423
  say('');
@@ -424,44 +467,48 @@ export async function initCommand(options) {
424
467
  return;
425
468
  }
426
469
  }
427
- // Browser capture's registration (rule 8b), which the preview announced: checked with Haystack now, and when the files
428
- // need a new key the telemetry changes are planned again with it (or without capture's, when it is refused).
470
+ // Init's own changes (the agent instructions, the Stop hook) first, all or nothing: a failure there is init's.
471
+ try {
472
+ applyAll(gitRoot, plan.changes.slice(0, telemetryFrom), say);
473
+ }
474
+ catch (error) {
475
+ throw new Error(`${errorText(error)} Every file init changed was restored; nothing was changed.`);
476
+ }
477
+ // Then telemetry, all or nothing on its own. Browser capture's registration (rule 8b), which the preview announced, is
478
+ // checked with Haystack first, and when the files need a new key the telemetry changes are planned again with it (or
479
+ // without capture's, when it is refused).
429
480
  const approved = options.yes === true || plan.changes.length > 0;
430
- const capture = telemetryPlan.captureRegistration
431
- ? await telemetryModule.prepareCapture(telemetryPlan.captureRegistration, token, say, approved) : null;
432
- if (capture?.replan) {
433
- telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, { ...telemetryOptions, registration: capture.replan });
434
- plan.changes.splice(telemetryFrom, plan.changes.length - telemetryFrom, ...telemetryPlan.changes);
435
- }
436
- // All or nothing: a change that fails (an install that does not end as planned) restores every file the changes
437
- // touched, so no edit is left referencing what was never installed.
438
- // Bytes, not text: a lockfile can be binary (bun.lockb).
439
- const before = new Map();
440
- for (const planned of plan.changes) {
441
- for (const path of [planned.target, ...(planned.run?.touches ?? [])]) {
442
- if (!before.has(path))
443
- before.set(path, existsSync(path) ? readFileSync(path) : null);
481
+ let capture = null;
482
+ if (telemetryFailure === null) {
483
+ try {
484
+ capture = telemetryPlan.captureRegistration
485
+ ? await telemetryModule.prepareCapture(telemetryPlan.captureRegistration, token, say, approved) : null;
486
+ if (capture?.replan) {
487
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, { ...telemetryOptions, registration: capture.replan });
488
+ plan.changes.splice(telemetryFrom, plan.changes.length - telemetryFrom, ...telemetryPlan.changes);
489
+ }
490
+ applyAll(gitRoot, plan.changes.slice(telemetryFrom), say);
444
491
  }
445
- }
446
- try {
447
- for (const planned of plan.changes) {
448
- applyChange(gitRoot, planned);
449
- say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : planned.action === 'delete' ? 'Deleted' : 'Updated'} ${planned.path}`));
492
+ catch (error) {
493
+ try {
494
+ await capture?.rollback(say);
495
+ }
496
+ catch (rollbackError) {
497
+ say(chalk.yellow(`Capture's registration could not be put back: ${errorText(rollbackError)}`));
498
+ }
499
+ telemetryFailed(error);
450
500
  }
451
501
  }
452
- catch (error) {
453
- for (const [path, content] of before) {
454
- if (content !== null)
455
- writeFileSync(path, content);
456
- else if (existsSync(path))
457
- unlinkSync(path);
502
+ if (telemetryFailure === null && capture) {
503
+ try {
504
+ const captureFollowUp = await capture.finish(say);
505
+ if (captureFollowUp)
506
+ telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, captureFollowUp);
507
+ }
508
+ catch (error) {
509
+ telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, `Run \`haystack init\` again: Haystack did not take the app's origins (${errorText(error)}).`);
458
510
  }
459
- await capture?.rollback(say);
460
- throw new Error(`${error instanceof Error ? error.message : String(error)} Every file init changed was restored; nothing was changed.`);
461
511
  }
462
- const captureFollowUp = await capture?.finish(say);
463
- if (captureFollowUp)
464
- telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, captureFollowUp);
465
512
  let onboarding;
466
513
  try {
467
514
  // The notes first: onboarding plans from the facts it starts with, and new notes are new facts (a new onboarding).
@@ -485,8 +532,18 @@ export async function initCommand(options) {
485
532
  reportOnboardingState(onboarding);
486
533
  say(formatOnboarding(onboarding));
487
534
  say('');
488
- telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
489
- for (const line of telemetryModule.formatTelemetry(telemetry.telemetry, telemetry.steps))
535
+ if (telemetryFailure === null) {
536
+ try {
537
+ telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
538
+ }
539
+ catch (error) {
540
+ // What Haystack holds could not be read: the report as previewed (`received` unchecked), and why.
541
+ const preview = telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
542
+ telemetry = { telemetry: preview.telemetry, steps: [...preview.steps, `Run \`haystack init --json\` again to read what telemetry Haystack has received (it could not: ${errorText(error)}).`] };
543
+ }
544
+ }
545
+ const shown = report();
546
+ for (const line of telemetryModule.formatTelemetry(shown.telemetry, shown.steps))
490
547
  say(line);
491
548
  say('');
492
549
  if (onboarding.state === 'ready')
@@ -4,13 +4,14 @@
4
4
  * locks older versions of packages the CLI also uses):
5
5
  * - yarn 1, yarn 4 and bun add only the new descriptors and entries; nothing else moves.
6
6
  * - npm hoists the new tree and replaces hoisted packages the app already locks (ws, yaml, zod moved to the CLI's
7
- * versions); with `--install-strategy=nested` it places the new tree under the dependency and moves nothing.
7
+ * versions), and re-resolves other entries even with `--install-strategy=nested` (39 in fosrl/pangolin): init never
8
+ * runs it (init-telemetry.ts leaves the npm install to the person, who sees the lockfile's diff).
8
9
  * - pnpm re-resolves the dependencies of every package the new tree shares with the old one to the newest version in the
9
10
  * graph (47 entries in Rallly, from @smithy/* to express), and no flag stops it. So init lets pnpm resolve the new
10
11
  * dependency (`--lockfile-only`), keeps every entry the lockfile had, adds the entries only the new dependency
11
12
  * reaches (mergePnpmLockfile), and installs exactly that (`pnpm install --frozen-lockfile`, which also proves it).
12
- * lockfileDrift checks the result for every format init can read, so a package manager that moves anything else fails the
13
- * change (and init restores every file).
13
+ * lockfileDrift checks the result for every format init runs, so a package manager that moves anything else fails the
14
+ * change (and init restores telemetry's files, leaving telemetry manual).
14
15
  */
15
16
  function unquote(text) {
16
17
  if (text.startsWith('\'') && text.endsWith('\''))
@@ -271,11 +272,6 @@ function entriesOf(format, text, importer) {
271
272
  }
272
273
  }
273
274
  }
274
- else if (format === 'npm') {
275
- const packages = JSON.parse(text).packages ?? {};
276
- for (const [key, value] of Object.entries(packages))
277
- out.set(key === (importer === '.' ? '' : importer) ? IMPORTER : key, JSON.stringify(value));
278
- }
279
275
  else if (format === 'yarn') {
280
276
  for (const block of text.split(/\n\s*\n/)) {
281
277
  const lines = block.split('\n').filter(line => line.trim() !== '' && !line.startsWith('#'));
@@ -22,7 +22,7 @@ import { basename, dirname, join, resolve } from 'node:path';
22
22
  import { createInterface } from 'node:readline/promises';
23
23
  import chalk from 'chalk';
24
24
  import { resolveAuthContext } from '../utils/auth.js';
25
- import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
25
+ import { classifyHttpError, HaystackApiError, repoApiPath } from '../utils/haystack-api.js';
26
26
  import { gatewayFetch } from './case-batch.js';
27
27
  import { TELEMETRY_ENVIRONMENT, TELEMETRY_SERVER_STATUS_PATH, TELEMETRY_TOKEN_PATH, TELEMETRY_TOKEN_SHA256, TELEMETRY_TOKEN_VERSION, } from './capture-contract.js';
28
28
  /** GitHub's owner and repository name alphabets. */
@@ -51,7 +51,7 @@ async function confirmed(question) {
51
51
  }
52
52
  /** GET the repository's server-telemetry status: its active token's hash, the endpoint, the newest flush. */
53
53
  export async function readTelemetryServerStatus(repository, token) {
54
- const path = `${TELEMETRY_SERVER_STATUS_PATH}?repository=${encodeURIComponent(repository)}`;
54
+ const path = repoApiPath(repository, TELEMETRY_SERVER_STATUS_PATH);
55
55
  const response = await gatewayFetch(path, token);
56
56
  if (!response.ok)
57
57
  throw await classifyHttpError(response, `Haystack API ${TELEMETRY_SERVER_STATUS_PATH}`);
@@ -199,7 +199,7 @@ async function mintUnderLock(repository, file, authToken, options) {
199
199
  let answer;
200
200
  try {
201
201
  const request = { repository, tokenSha256, rotate: options.rotate === true };
202
- const response = await gatewayFetch(`${TELEMETRY_TOKEN_PATH}?repository=${encodeURIComponent(repository)}`, authToken, {
202
+ const response = await gatewayFetch(repoApiPath(repository, TELEMETRY_TOKEN_PATH), authToken, {
203
203
  method: 'POST', body: JSON.stringify(request),
204
204
  });
205
205
  if (!response.ok)
@@ -5,10 +5,10 @@
5
5
  * - `haystack login --headless [--label NAME] [--scope repo:owner/name,...]`
6
6
  * Opens the browser to mint a token via the auth-worker, then writes it
7
7
  * locally so subsequent CLI calls auth via Bearer.
8
- * - `haystack tokens list`
8
+ * - `haystack auth tokens`
9
9
  * Lists tokens on the server side (the local file only stores ONE
10
10
  * active token at a time; the server may have many tied to your user).
11
- * - `haystack tokens revoke <id>`
11
+ * - `haystack auth revoke <id>`
12
12
  * Revokes a server-side token. If the revoked token is the one
13
13
  * currently on disk, the local file is wiped so the next CLI call
14
14
  * doesn't keep using a dead token.
@@ -123,12 +123,12 @@ export async function headlessLoginCommand(opts) {
123
123
  console.log(` ${chalk.dim('path:')} ${path}`);
124
124
  console.log(` ${chalk.dim('id:')} ${result.id}`);
125
125
  console.log(chalk.dim(' Subsequent `haystack` calls will use this token.'));
126
- console.log(chalk.dim(' Revoke server-side with: haystack tokens revoke ' + result.id));
126
+ console.log(chalk.dim(' Revoke server-side with: haystack auth revoke ' + result.id));
127
127
  }
128
128
  export async function headlessLogoutCommand() {
129
129
  await clearHeadlessToken();
130
130
  console.log(chalk.green('✓ Headless token removed from disk.'));
131
- console.log(chalk.dim(' Server-side token is still active. Revoke it with `haystack tokens revoke <id>`.'));
131
+ console.log(chalk.dim(' Server-side token is still active. Revoke it with `haystack auth revoke <id>`.'));
132
132
  }
133
133
  export async function listTokensCommand(opts) {
134
134
  const token = await loadToken();
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { resolveAuthContext } from '../utils/auth.js';
3
- import { haystackJson } from '../utils/haystack-api.js';
3
+ import { haystackJson, repoApiPath } from '../utils/haystack-api.js';
4
4
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
5
5
  const CASE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
6
6
  const ACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -27,8 +27,8 @@ async function context(runId, repository) {
27
27
  const { token } = await resolveAuthContext({ owner, repo });
28
28
  return {
29
29
  token,
30
- path: `/api/agent/cloud-verifier/runs/${runId}/cases`,
31
- query: `?repository=${encodeURIComponent(repository)}`,
30
+ path: repoApiPath(repository, `/runs/${runId}/cases`),
31
+ query: '',
32
32
  };
33
33
  }
34
34
  export async function verifyCasesCommand(runId, options) {
@@ -1,5 +1,5 @@
1
1
  import { resolveAuthContext } from '../utils/auth.js';
2
- import { haystackJson, HaystackApiError } from '../utils/haystack-api.js';
2
+ import { haystackJson, HaystackApiError, repoApiPath } from '../utils/haystack-api.js';
3
3
  const HEALTH = ['healthy', 'degraded', 'infrastructure-error', 'not-executed', 'unknown'];
4
4
  const LIFECYCLES = ['complete', 'failed', 'cancelled', 'budget-stopped', 'account-action'];
5
5
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
@@ -139,7 +139,7 @@ export async function verifyHistoryCommand(repository, options) {
139
139
  const auth = await resolveAuthContext({ preferredLogin: options.account, owner, repo: name });
140
140
  let response;
141
141
  try {
142
- response = await haystackJson(`/api/agent/cloud-verifier/history/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?limit=${limit}`, auth.token, { method: 'GET' });
142
+ response = await haystackJson(repoApiPath(`${owner}/${name}`, `/runs?limit=${limit}`), auth.token, { method: 'GET' });
143
143
  }
144
144
  catch (error) {
145
145
  if (error instanceof HaystackApiError && error.status === 404) {
@@ -10,17 +10,14 @@
10
10
  import chalk from 'chalk';
11
11
  import { withSchema } from '../schema.js';
12
12
  import { resolveAuthContext } from '../utils/auth.js';
13
- import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
13
+ import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, repoApiPath, } from '../utils/haystack-api.js';
14
14
  import { trackOnboardingEvent } from '../utils/telemetry.js';
15
15
  import { gatewayFetch } from './case-batch.js';
16
16
  import { ONBOARDING_STAGES, ONBOARDING_VERSION, } from './onboarding-contract.js';
17
17
  import { sameNotes } from './onboarding-notes.js';
18
18
  import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
19
19
  import { findGitRoot } from '../utils/hooks.js';
20
- const ONBOARDING_PATH = '/api/agent/cloud-verifier/onboarding';
21
- const FACTS_PATH = `${ONBOARDING_PATH}/facts`;
22
- const ANSWERS_PATH = `${ONBOARDING_PATH}/answers`;
23
- const NOTES_PATH = `${ONBOARDING_PATH}/notes`;
20
+ const onboardingPath = (repository, rest = '') => repoApiPath(repository, `/onboarding${rest}`);
24
21
  const ONBOARD_RUN_ID = /^ob_[0-9a-f]{48}$/;
25
22
  const SHA256 = /^[0-9a-f]{64}$/;
26
23
  const COMMIT = /^[0-9a-f]{40}$/;
@@ -155,11 +152,11 @@ async function body(response, what) {
155
152
  export async function readOnboardingStatus(repository, baseCommit, token, onboardRunId = null) {
156
153
  if (!COMMIT.test(baseCommit))
157
154
  throw new Error(`Invalid base commit ${baseCommit}.`);
158
- const path = `${ONBOARDING_PATH}?repository=${encodeURIComponent(repository)}&baseCommit=${baseCommit}`
159
- + (onboardRunId === null ? '' : `&onboardRunId=${encodeURIComponent(onboardRunId)}`);
155
+ const path = onboardingPath(repository, `?base=${baseCommit}`)
156
+ + (onboardRunId === null ? '' : `&run=${encodeURIComponent(onboardRunId)}`);
160
157
  const response = await gatewayFetch(path, token);
161
158
  if (!response.ok)
162
- throw await classifyHttpError(response, `Haystack API ${ONBOARDING_PATH}`);
159
+ throw await classifyHttpError(response, `Haystack API ${path}`);
163
160
  return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
164
161
  }
165
162
  /** Rule 12's start POST: onboard this base now, before any change exists. Idempotent: a
@@ -167,11 +164,11 @@ export async function readOnboardingStatus(repository, baseCommit, token, onboar
167
164
  export async function startOnboarding(repository, baseCommit, token) {
168
165
  if (!COMMIT.test(baseCommit))
169
166
  throw new Error(`Invalid base commit ${baseCommit}.`);
170
- const response = await gatewayFetch(`${ONBOARDING_PATH}?repository=${encodeURIComponent(repository)}`, token, {
167
+ const response = await gatewayFetch(onboardingPath(repository), token, {
171
168
  method: 'POST', body: JSON.stringify({ repository, baseCommit }),
172
169
  });
173
170
  if (!response.ok)
174
- throw await classifyHttpError(response, `Haystack API ${ONBOARDING_PATH}`);
171
+ throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository)}`);
175
172
  return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
176
173
  }
177
174
  /** How often a command reads onboarding status while it runs. The status route holds no read (a crawl's reads wait for a
@@ -357,9 +354,9 @@ function repositoryOf(value) {
357
354
  return `${origin.owner}/${origin.repository}`;
358
355
  }
359
356
  export async function readFacts(repository, token) {
360
- const response = await gatewayFetch(`${FACTS_PATH}?repository=${encodeURIComponent(repository)}`, token);
357
+ const response = await gatewayFetch(onboardingPath(repository, '/facts'), token);
361
358
  if (!response.ok)
362
- throw await classifyHttpError(response, `Haystack API ${FACTS_PATH}`);
359
+ throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/facts')}`);
363
360
  return parseFactsView(await body(response, 'facts'), repository);
364
361
  }
365
362
  /** `haystack verify answer <question-id> <choice>`: files the answer to an open
@@ -379,7 +376,7 @@ export async function verifyAnswerCommand(questionId, choice, options) {
379
376
  if (!question.choices.includes(choice)) {
380
377
  throw new Error(`"${choice}" is not one of the question's choices: ${question.choices.map(item => JSON.stringify(item)).join(', ')}.`);
381
378
  }
382
- const response = await gatewayFetch(`${ANSWERS_PATH}?repository=${encodeURIComponent(repository)}`, auth.token, {
379
+ const response = await gatewayFetch(onboardingPath(repository, '/answers'), auth.token, {
383
380
  method: 'POST', body: JSON.stringify({ repository, expectedFactsDigest: facts.factsDigest, questionId, answer: choice }),
384
381
  });
385
382
  if (response.status === 409 && attempt < 3) {
@@ -387,7 +384,7 @@ export async function verifyAnswerCommand(questionId, choice, options) {
387
384
  continue;
388
385
  }
389
386
  if (!response.ok)
390
- throw await classifyHttpError(response, `Haystack API ${ANSWERS_PATH}`);
387
+ throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/answers')}`);
391
388
  const view = parseFactsView(await body(response, 'facts'), repository);
392
389
  if (options.json) {
393
390
  process.stdout.write(`${JSON.stringify(withSchema('verify-answer', { facts: view }), null, 2)}\n`);
@@ -407,7 +404,7 @@ export async function writeOnboardingNotes(repository, notes, token) {
407
404
  if (sameNotes(facts.facts.notes, notes))
408
405
  return 'unchanged';
409
406
  const request = { repository, expectedFactsDigest: facts.factsDigest, notes };
410
- const response = await gatewayFetch(`${NOTES_PATH}?repository=${encodeURIComponent(repository)}`, token, {
407
+ const response = await gatewayFetch(onboardingPath(repository, '/notes'), token, {
411
408
  method: 'POST', body: JSON.stringify(request),
412
409
  });
413
410
  if (response.status === 409 && attempt < 3) {
@@ -415,7 +412,7 @@ export async function writeOnboardingNotes(repository, notes, token) {
415
412
  continue;
416
413
  }
417
414
  if (!response.ok)
418
- throw await classifyHttpError(response, `Haystack API ${NOTES_PATH}`);
415
+ throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/notes')}`);
419
416
  if (!sameNotes(parseFactsView(await body(response, 'facts'), repository).facts.notes, notes))
420
417
  invalid('the notes it answered with');
421
418
  return 'written';
@@ -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, haystackFetch, 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, repoApiPath, } 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';
@@ -325,8 +325,7 @@ function precomputeResult(response) {
325
325
  * service no longer has it). */
326
326
  async function uploadSourcePatch(request, token, signal, force = false) {
327
327
  const gzipped = Buffer.from(request.patchGzBase64, 'base64');
328
- const path = `/api/agent/cloud-verifier/source-patches/${request.patchSha256}`
329
- + `?owner=${encodeURIComponent(request.owner)}&repository=${encodeURIComponent(request.repository)}`;
328
+ const path = repoApiPath(`${request.owner}/${request.repository}`, `/patches/${request.patchSha256}`);
330
329
  const found = force ? null : await haystackFetch(path, token, { method: 'HEAD', signal }, [404]);
331
330
  if (found === null || found.status === 404) {
332
331
  await haystackFetch(path, token, { method: 'PUT', headers: { 'Content-Type': 'application/gzip' }, body: gzipped, signal });
@@ -339,7 +338,7 @@ async function submitCapture(request, token, signal) {
339
338
  const { patchGzBase64: _inline, ...named } = request;
340
339
  const submit = async (force) => {
341
340
  const patchBytes = await uploadSourcePatch(request, token, signal, force);
342
- return haystackJson('/api/agent/cloud-verifier/front-half', token, { method: 'POST',
341
+ return haystackJson(repoApiPath(`${request.owner}/${request.repository}`, '/checks'), token, { method: 'POST',
343
342
  headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...named, patchBytes }), signal });
344
343
  };
345
344
  try {