@haystackeditor/cli 0.27.0 → 0.27.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.
package/README.md CHANGED
@@ -41,10 +41,13 @@ or `HAYSTACK_TELEMETRY_DISABLED=1` to turn this telemetry off.
41
41
 
42
42
  Most users of this CLI are coding agents. What to know:
43
43
 
44
- - **After a change, run `haystack verify`** inside the checkout. Nothing needs
45
- to be committed or pushed. Say what you were asked to do with
46
- `--intent "<the task, in the user's words>"`, and what to try with
47
- `--idea "<as you would tell a tester>"` (repeat it for more).
44
+ - **After a change, two steps** inside the checkout (nothing needs to be
45
+ committed or pushed):
46
+ 1. `haystack pre-verify` shows what the change touches, what production and
47
+ real users do there, and Haystack's ideas of what could break.
48
+ 2. `haystack verify --intent "<the task, in the user's words>" --idea "<as you
49
+ would tell a tester>"` explores the app with your steering: keep the ideas
50
+ worth trying, add your own (repeat `--idea`); yours are explored first.
48
51
  - **First time in a repository**, `haystack init --yes` sets it up and starts
49
52
  onboarding the app. `haystack verify onboarding` shows where onboarding is.
50
53
  When onboarding asks a question, answer it with
@@ -74,10 +77,11 @@ It then finds your crawl of that exact capture: the same code and the same
74
77
  change title (HEAD's subject, which tells the crawl's judge what the change is;
75
78
  committing or rewording keeps the code but changes the title). When there is
76
79
  none, or the one it finds was cancelled, stopped before finishing or is being
77
- cancelled, it submits the capture through the same request `haystack verify
78
- precompute` sends (the service starts the crawl, or runs the stopped one again
79
- once it has shut down), says so in one line, and follows the crawl the service
80
- acknowledged; it never shows a crawl of another revision or another title.
80
+ cancelled, or the stop hook only built it, it asks the service for the crawl
81
+ (the service starts it, runs the stopped one again once it has shut down, or
82
+ turns the hook's prepared run into a crawl that starts from the app the hook
83
+ already built), says so in one line, and follows that crawl; it never shows a
84
+ crawl of another revision or another title.
81
85
 
82
86
  A crawl builds the app with and without your change, starts from the changed
83
87
  code, reaches it in the running app, explores outward with both builds side by
@@ -108,18 +112,20 @@ up, what became of each idea, and what never ran (`haystack schema verify`).
108
112
  it is large (`haystack schema verify-raw`). Exit codes: 0 when the crawl
109
113
  answered or finished (the bugs
110
114
  it found are in the output) or is still running under `--no-wait`; 2 when it ended
111
- without finishing (stopped early, or cancelled because a newer stop in the
112
- repository replaced it) or the machines it used could not be proven shut down;
115
+ without finishing (stopped early, or cancelled) or the machines it used could
116
+ not be proven shut down;
113
117
  1 when the command failed or no crawl could be started.
114
118
 
115
119
  **The stop hook.** `haystack init` installs a Claude Code Stop hook that runs
116
120
  `haystack verify precompute --hook` whenever an agent turn stops. It captures
117
- the checkout within five seconds and hands the request to a detached sender;
118
- the service then starts the change's crawl in the background, so
119
- `haystack verify` usually finds the crawl already running or finished. A newer
120
- stop in the same repository replaces an older crawl that has not finished.
121
- `haystack verify precompute` (without `--hook`) submits the same capture in the
122
- foreground and prints one line for the crawl it started.
121
+ the checkout within five seconds and hands the request to a detached sender.
122
+ The service then builds the app with and without the change, works out what
123
+ the change touches (the brief `haystack pre-verify` prints) and sets up starting
124
+ points next to it, and stops there: it never crawls. `haystack verify` then
125
+ crawls from that prepared app instead of building it again. No run cancels
126
+ another: each runs to its end unless it is cancelled. `haystack verify precompute`
127
+ (without `--hook`) submits the same capture in the foreground and prints one
128
+ line for the run it started.
123
129
 
124
130
  Retained fleet cases remain available through their exact case IDs:
125
131
 
@@ -62,7 +62,8 @@ export function recordedApps(gitRoot, candidates) {
62
62
  return readCaptureConfig(join(gitRoot, dir))?.app === dir;
63
63
  }
64
64
  catch (error) {
65
- throw new Error(`${posix.join(dir, CAPTURE_CONFIG_PATH)}: ${error instanceof Error ? error.message : String(error)}`);
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ throw new Error(dir === '.' ? message : message.replace(CAPTURE_CONFIG_PATH, posix.join(dir, CAPTURE_CONFIG_PATH)));
66
67
  }
67
68
  });
68
69
  }
@@ -21,9 +21,9 @@
21
21
  * stores or reads one), production's environment, the start command, the deploy and the off switches (13c).
22
22
  */
23
23
  import { execFileSync, spawnSync } from 'node:child_process';
24
- import { existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
24
+ import { accessSync, constants, existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { createRequire } from 'node:module';
26
- import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
26
+ import { delimiter, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import chalk from 'chalk';
29
29
  import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
@@ -103,7 +103,8 @@ function section(json, name) {
103
103
  }
104
104
  const LOCKFILES = [['pnpm-lock.yaml', 'pnpm'], ['package-lock.json', 'npm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun']];
105
105
  /** The package manager that owns the app's lockfile: the nearest `packageManager` field, else the nearest lockfile, from
106
- * the app up to the repository root. null: no lockfile, so the dependency is written into package.json itself. */
106
+ * the app up to the repository root (`from` says which). null: no lockfile, so the dependency is written into package.json
107
+ * itself. */
107
108
  function packageManager(gitRoot, appDir) {
108
109
  for (let directory = appDir;; directory = dirname(directory)) {
109
110
  const manifest = join(directory, 'package.json');
@@ -111,16 +112,32 @@ function packageManager(gitRoot, appDir) {
111
112
  const declared = readPackage(manifest).json.packageManager;
112
113
  const name = typeof declared === 'string' ? /^(pnpm|npm|yarn|bun)@/.exec(declared)?.[1] : undefined;
113
114
  if (name)
114
- return { name: name, root: directory };
115
+ return { name: name, root: directory, from: `${inside(gitRoot, manifest)}'s packageManager field` };
115
116
  }
116
117
  for (const [lockfile, name] of LOCKFILES) {
117
118
  if (isFile(join(directory, lockfile)))
118
- return { name, root: directory };
119
+ return { name, root: directory, from: inside(gitRoot, join(directory, lockfile)) };
119
120
  }
120
121
  if (directory === gitRoot || dirname(directory) === directory)
121
122
  return null;
122
123
  }
123
124
  }
125
+ /** Whether `command` is an executable on PATH (a package manager init would run, checked before it plans to). */
126
+ function onPath(command) {
127
+ const extensions = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
128
+ for (const directory of (process.env.PATH ?? '').split(delimiter)) {
129
+ if (directory === '')
130
+ continue;
131
+ for (const extension of extensions) {
132
+ try {
133
+ accessSync(join(directory, `${command}${extension}`), constants.X_OK);
134
+ return true;
135
+ }
136
+ catch { /* not in this directory */ }
137
+ }
138
+ }
139
+ return false;
140
+ }
124
141
  /** The first release whose sites record repository-relative paths (what a registered tenant's src_prefix '' relies on)
125
142
  * and that ships the `./next` entry: earlier releases are not compatible with either integration. */
126
143
  const CLI_COMPATIBLE_SINCE = [0, 24, 0];
@@ -216,14 +233,29 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
216
233
  const spec = `${CLI_PACKAGE}@${version}`;
217
234
  const dev = field === 'devDependencies';
218
235
  const workspaceRoot = isFile(join(appDir, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [];
219
- // Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts): npm with its tree
220
- // nested under the CLI (hoisting would replace packages the app already locks), pnpm through the merge.
236
+ // Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts; pnpm through the merge).
221
237
  const argv = manager === null ? null : {
222
238
  pnpm: ['pnpm', 'add', '--save-exact', dev ? '--save-dev' : '--save-prod', ...workspaceRoot, spec],
223
- npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', '--install-strategy=nested', spec],
239
+ npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', spec],
224
240
  yarn: ['yarn', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
225
241
  bun: ['bun', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
226
242
  }[manager.name];
243
+ const appPath = inside(gitRoot, appDir) || '.';
244
+ // npm re-resolves entries the CLI does not own whatever the flags (39 in fosrl/pangolin, with --install-strategy=nested
245
+ // too), so init never rewrites an npm lockfile: the person adds the CLI with npm and sees the lockfile's diff.
246
+ if (manager?.name === 'npm' && argv) {
247
+ return { kind: 'manual', why: `${appPath} uses npm (${manager.from}), whose install also re-resolves lockfile entries the CLI does not own, so init`
248
+ + ` does not run it. Run \`${argv.join(' ')}\` in ${appPath}, check package-lock.json's diff, then run \`haystack init\` again` };
249
+ }
250
+ // A package manager that is not installed is never run (rule 13: telemetry never fails init).
251
+ if (manager && argv && !onPath(manager.name)) {
252
+ const provide = (manager.name === 'pnpm' || manager.name === 'yarn') && onPath('corepack')
253
+ ? `run \`corepack enable ${manager.name}\` (corepack is installed here)`
254
+ : `install it (${manager.name === 'bun' ? 'https://bun.sh' : manager.name === 'yarn' ? 'https://yarnpkg.com/getting-started/install' : 'https://pnpm.io/installation'})`;
255
+ return { kind: 'manual', why: `${appPath} uses ${manager.name} (${manager.from}), which is not installed here, so init did not run it. Either`
256
+ + ` ${provide} and run \`haystack init\` again, or run \`${argv.join(' ')}\` in ${appPath} wherever ${manager.name} is installed and then`
257
+ + ' run `haystack init` again' };
258
+ }
227
259
  const action = promoting ? `Moved from devDependencies to dependencies at ${version}` : `Pinned to ${version}`;
228
260
  const lockfileNote = manager?.name === 'pnpm'
229
261
  ? `, keeping every other entry of pnpm's lockfile as it was (pnpm's own add re-resolves packages the CLI shares with the app; init adds only the CLI's entries, then \`pnpm install --frozen-lockfile\`)`
@@ -247,12 +279,14 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
247
279
  }
248
280
  /** The lockfile a package manager writes, in a format lockfile-pin.ts reads (bun's binary lockb aside). */
249
281
  function textLockfile(manager) {
250
- const name = { pnpm: 'pnpm-lock.yaml', npm: 'package-lock.json', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
282
+ if (manager.name === 'npm')
283
+ throw new Error('init never runs npm to pin the CLI');
284
+ const name = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
251
285
  const path = join(manager.root, name);
252
286
  return isFile(path) ? { path, format: manager.name } : null;
253
287
  }
254
288
  /** Runs the add, so that the lockfile gains the CLI's entries and nothing else moves (rule 13); throws (init then restores
255
- * every file) when the package manager moved anything else. */
289
+ * telemetry's files and leaves telemetry manual) when the package manager moved anything else. */
256
290
  function pin(manager, appDir, argv) {
257
291
  const run = (command) => { execFileSync(command[0], command.slice(1), { cwd: appDir, stdio: ['ignore', 2, 2] }); };
258
292
  const lockfile = textLockfile(manager);
@@ -272,8 +306,8 @@ function pin(manager, appDir, argv) {
272
306
  const moved = lockfileDrift(lockfile.format, before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE);
273
307
  if (moved.length > 0) {
274
308
  throw new Error(`\`${argv.join(' ')}\` also changed ${moved.length} lockfile ${moved.length === 1 ? 'entry' : 'entries'} the CLI does not own`
275
- + ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}), so init put every file back. Add ${CLI_PACKAGE} at its exact version`
276
- + ` to ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
309
+ + ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}). Add ${CLI_PACKAGE} at its exact version to`
310
+ + ` ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
277
311
  }
278
312
  }
279
313
  function isNode(value) {
@@ -1006,6 +1040,22 @@ export function previewTelemetry(plan, repository, notes) {
1006
1040
  const profile = dbProfile(repository, null, notes);
1007
1041
  return { telemetry: { capture: plan.capture, server: serverPartOf(plan), dbProfileCommand: profile?.command ?? null }, steps };
1008
1042
  }
1043
+ /** A telemetry plan with nothing planned, for a run whose telemetry could not be planned at all (failedTelemetry says why). */
1044
+ export function emptyTelemetryPlan() {
1045
+ return { app: null, changes: [], notices: [], server: { status: 'manual', kind: null, steps: [] }, settings: null,
1046
+ capture: { status: 'manual', steps: [], received: null }, captureRegistration: null };
1047
+ }
1048
+ /** Telemetry that could not be set up (a package manager failed, a file init could not read, Haystack not answering): each
1049
+ * part it would have set up is manual with what happened, every file telemetry changed is back, and the rest of init stands
1050
+ * (telemetry never fails init). */
1051
+ export function failedTelemetry(plan, failure) {
1052
+ const step = `init could not set up telemetry: ${failure.replace(/\.?\s*$/, '')}. It put back every file telemetry changed; the rest of init is`
1053
+ + ' done. Fix that, then run `haystack init` again.';
1054
+ // A part that was already waiting for answers keeps its questions after what happened.
1055
+ const part = (value) => (value.status === 'unsupported' ? value
1056
+ : { status: 'manual', steps: [step, ...(value.status === 'manual' ? value.steps : [])], received: null });
1057
+ return { telemetry: { capture: part(plan.capture), server: part(serverPartOf(plan)), dbProfileCommand: null }, steps: [step] };
1058
+ }
1009
1059
  /** Rule 8(e): the report, and what the person does for telemetry to arrive (the token, production's environment, the start,
1010
1060
  * the deploy) with the off switches (rule 13c). `onboarding` and `notes` feed the data profile. */
1011
1061
  export async function telemetryReport(plan, context) {
@@ -45,16 +45,21 @@ export const AGENT_NOTE = [
45
45
  NOTE_BEGIN,
46
46
  '## Haystack',
47
47
  '',
48
- 'After you change this app\'s code, or when the user asks you to check a change, run `haystack verify` before you',
49
- 'call the work done. It runs the app with and without the change, explores it, and reports where the change shows up',
50
- 'and what broke. It changes nothing, so run it without asking.',
48
+ 'After you change this app\'s code, or when the user asks you to check a change, check it with Haystack before you call',
49
+ 'the work done. It runs the app with and without the change, explores it, and reports what broke. It changes nothing,',
50
+ 'so run it without asking.',
51
51
  '',
52
- '- It often takes 10 to 15 minutes: give it your longest timeout or run it in the background, and do not pipe it',
52
+ '1. `haystack pre-verify`: what your change touches, what production and real users do there, and Haystack\'s ideas',
53
+ ' of what could break.',
54
+ '2. `haystack verify --intent "<what you were asked to do, in the user\'s words>" --idea "<something to try>"`: keep the',
55
+ ' ideas worth trying, add your own (repeat --idea); yours are explored first.',
56
+ '',
57
+ '- `verify` often takes 10 to 15 minutes: give it your longest timeout or run it in the background, and do not pipe it',
53
58
  ' through `tail` or `head`, which hide its progress. If your command times out, run it again: it picks up the same crawl.',
54
59
  '- Fix each bug it reports that your change caused, then run `haystack verify` again.',
55
60
  '- If it says onboarding needs an answer, ask the user that question and file their choice with',
56
61
  ' `haystack verify answer <question-id> <choice>`.',
57
- '- Read its short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
62
+ '- Read verify\'s short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
58
63
  ' `haystack verify --help` explains the output and exit codes.',
59
64
  '- If `haystack` is not installed, run each command as `npx -y -p @haystackeditor/cli@latest haystack <command>`.',
60
65
  '- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
@@ -299,6 +304,33 @@ export function planInit(gitRoot) {
299
304
  const notices = [];
300
305
  return { changes: [...planStopHook(gitRoot, notices), ...planAgentNotes(gitRoot, notices)], notices };
301
306
  }
307
+ /** Makes `changes` all or nothing: one that fails (an install that does not end as planned) restores every file they
308
+ * touched (bytes, not text: a lockfile can be binary), so no edit is left referencing what was never installed. */
309
+ function applyAll(gitRoot, changes, say) {
310
+ const before = new Map();
311
+ for (const planned of changes) {
312
+ for (const path of [planned.target, ...(planned.run?.touches ?? [])]) {
313
+ if (!before.has(path))
314
+ before.set(path, existsSync(path) ? readFileSync(path) : null);
315
+ }
316
+ }
317
+ try {
318
+ for (const planned of changes) {
319
+ applyChange(gitRoot, planned);
320
+ say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : planned.action === 'delete' ? 'Deleted' : 'Updated'} ${planned.path}`));
321
+ }
322
+ }
323
+ catch (error) {
324
+ for (const [path, content] of before) {
325
+ if (content !== null)
326
+ writeFileSync(path, content);
327
+ else if (existsSync(path))
328
+ unlinkSync(path);
329
+ }
330
+ throw error;
331
+ }
332
+ }
333
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
302
334
  function applyChange(gitRoot, planned) {
303
335
  const inside = relative(gitRoot, planned.target);
304
336
  if (!inside.startsWith('..') && !isAbsolute(inside))
@@ -362,19 +394,35 @@ export async function initCommand(options) {
362
394
  // Read before anything else, so notes that cannot be used stop init with nothing done.
363
395
  const notes = options.notes === undefined ? null : readNotes(options.notes);
364
396
  const plan = planInit(gitRoot);
365
- // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it.
397
+ // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it. It never fails
398
+ // init: whatever goes wrong with it (a file it cannot read, a package manager that fails, Haystack not answering) puts its
399
+ // own files back and leaves it manual with what happened, and the rest of init goes on.
366
400
  const telemetryModule = await import('./init-telemetry.js');
367
401
  const telemetryOptions = { app: options.app, origins: options.origin ?? [], consent: options.consent, urlRewrites: options.urlRewrites };
368
- let telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
402
+ let telemetryFailure = null;
403
+ let telemetryPlan = telemetryModule.emptyTelemetryPlan();
404
+ try {
405
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
406
+ }
407
+ catch (error) {
408
+ telemetryFailure = errorText(error);
409
+ }
369
410
  const telemetryFrom = plan.changes.length;
370
411
  plan.changes.push(...telemetryPlan.changes);
371
412
  plan.notices.push(...telemetryPlan.notices);
372
413
  let notesWritten = null;
373
414
  let telemetry = null;
415
+ /** Telemetry did not happen: its changes leave the plan (none of them was made), and the report says why. */
416
+ const telemetryFailed = (error) => {
417
+ telemetryFailure = errorText(error);
418
+ plan.changes.splice(telemetryFrom);
419
+ say(chalk.yellow(`Telemetry was not set up: ${telemetryFailure} The rest of init goes on; the telemetry report says what to do.`));
420
+ };
374
421
  // 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);
422
+ const report = () => (telemetryFailure !== null ? telemetryModule.failedTelemetry(telemetryPlan, telemetryFailure)
423
+ : telemetry ?? telemetryModule.previewTelemetry(telemetryPlan, repository, notes));
376
424
  const showPreview = () => {
377
- const preview = telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
425
+ const preview = report();
378
426
  for (const line of telemetryModule.formatTelemetry(preview.telemetry, preview.steps))
379
427
  say(line);
380
428
  say('');
@@ -424,47 +472,52 @@ export async function initCommand(options) {
424
472
  return;
425
473
  }
426
474
  }
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).
475
+ // Init's own changes (the agent instructions, the Stop hook) first, all or nothing: a failure there is init's.
476
+ try {
477
+ applyAll(gitRoot, plan.changes.slice(0, telemetryFrom), say);
478
+ }
479
+ catch (error) {
480
+ throw new Error(`${errorText(error)} Every file init changed was restored; nothing was changed.`);
481
+ }
482
+ // Then telemetry, all or nothing on its own. Browser capture's registration (rule 8b), which the preview announced, is
483
+ // checked with Haystack first, and when the files need a new key the telemetry changes are planned again with it (or
484
+ // without capture's, when it is refused).
429
485
  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);
486
+ let capture = null;
487
+ if (telemetryFailure === null) {
488
+ try {
489
+ capture = telemetryPlan.captureRegistration
490
+ ? await telemetryModule.prepareCapture(telemetryPlan.captureRegistration, token, say, approved) : null;
491
+ if (capture?.replan) {
492
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, { ...telemetryOptions, registration: capture.replan });
493
+ plan.changes.splice(telemetryFrom, plan.changes.length - telemetryFrom, ...telemetryPlan.changes);
494
+ }
495
+ applyAll(gitRoot, plan.changes.slice(telemetryFrom), say);
444
496
  }
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}`));
497
+ catch (error) {
498
+ try {
499
+ await capture?.rollback(say);
500
+ }
501
+ catch (rollbackError) {
502
+ say(chalk.yellow(`Capture's registration could not be put back: ${errorText(rollbackError)}`));
503
+ }
504
+ telemetryFailed(error);
450
505
  }
451
506
  }
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);
507
+ if (telemetryFailure === null && capture) {
508
+ try {
509
+ const captureFollowUp = await capture.finish(say);
510
+ if (captureFollowUp)
511
+ telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, captureFollowUp);
512
+ }
513
+ catch (error) {
514
+ telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, `Run \`haystack init\` again: Haystack did not take the app's origins (${errorText(error)}).`);
458
515
  }
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
516
  }
462
- const captureFollowUp = await capture?.finish(say);
463
- if (captureFollowUp)
464
- telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, captureFollowUp);
465
517
  let onboarding;
466
518
  try {
467
- // The notes first: onboarding plans from the facts it starts with, and new notes are new facts (a new onboarding).
519
+ // The notes first: onboarding plans from the facts it starts with (rule 2, amendment 34: new notes onboard again only while
520
+ // the repository has no setup, after a run that ended without one).
468
521
  if (notes) {
469
522
  notesWritten = await writeOnboardingNotes(repository, notes, token);
470
523
  say(notesWritten === 'written' ? chalk.green('✓ Wrote the onboarding notes') : chalk.dim('The onboarding notes are already these.'));
@@ -485,8 +538,18 @@ export async function initCommand(options) {
485
538
  reportOnboardingState(onboarding);
486
539
  say(formatOnboarding(onboarding));
487
540
  say('');
488
- telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
489
- for (const line of telemetryModule.formatTelemetry(telemetry.telemetry, telemetry.steps))
541
+ if (telemetryFailure === null) {
542
+ try {
543
+ telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
544
+ }
545
+ catch (error) {
546
+ // What Haystack holds could not be read: the report as previewed (`received` unchecked), and why.
547
+ const preview = telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
548
+ telemetry = { telemetry: preview.telemetry, steps: [...preview.steps, `Run \`haystack init --json\` again to read what telemetry Haystack has received (it could not: ${errorText(error)}).`] };
549
+ }
550
+ }
551
+ const shown = report();
552
+ for (const line of telemetryModule.formatTelemetry(shown.telemetry, shown.steps))
490
553
  say(line);
491
554
  say('');
492
555
  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('#'));
@@ -106,6 +106,13 @@ export function parseOnboardingStatus(value, expected) {
106
106
  invalid('its review');
107
107
  if (value.state !== 'ready' && review !== null)
108
108
  invalid('its review');
109
+ // Rule 2 (amendment 34): a ready status names the base its record was made at and when it was published.
110
+ const ready = value.state === 'ready';
111
+ if (ready ? !(typeof value.recordBaseCommit === 'string' && COMMIT.test(value.recordBaseCommit)) : value.recordBaseCommit !== null)
112
+ invalid('its record base');
113
+ if (ready ? !(typeof value.recordPublishedAt === 'string' && !Number.isNaN(Date.parse(value.recordPublishedAt)))
114
+ : value.recordPublishedAt !== null)
115
+ invalid('its record time');
109
116
  if (value.block !== null)
110
117
  parseOnboardingBlock(value.block);
111
118
  const checkpoint = value.checkpoint;
@@ -147,8 +154,8 @@ async function body(response, what) {
147
154
  throw new OnboardingResponseError(`The onboarding service returned ${what} that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
148
155
  }
149
156
  }
150
- /** The onboarding of `repository` at `baseCommit`, now; with `onboardRunId`, that run's (or the run a derivation change moved
151
- * it to), whatever derivation is live: what a waiting command follows. */
157
+ /** The onboarding of `repository`, asked at `baseCommit` (rule 2, amendment 34: one setup per repository, the same at every
158
+ * base); with `onboardRunId`, that run's, or the repository's record once it has one: what a waiting command follows. */
152
159
  export async function readOnboardingStatus(repository, baseCommit, token, onboardRunId = null) {
153
160
  if (!COMMIT.test(baseCommit))
154
161
  throw new Error(`Invalid base commit ${baseCommit}.`);
@@ -159,7 +166,7 @@ export async function readOnboardingStatus(repository, baseCommit, token, onboar
159
166
  throw await classifyHttpError(response, `Haystack API ${path}`);
160
167
  return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
161
168
  }
162
- /** Rule 12's start POST: onboard this base now, before any change exists. Idempotent: a
169
+ /** Rule 12's start POST: onboard the repository now, at this base, before any change exists. Idempotent: a
163
170
  * ready, blocked or running onboarding is answered as it is. */
164
171
  export async function startOnboarding(repository, baseCommit, token) {
165
172
  if (!COMMIT.test(baseCommit))
@@ -198,8 +205,7 @@ export async function waitForOnboarding(first, token, progress, resume) {
198
205
  await delay(ONBOARDING_READ_EVERY_MS);
199
206
  let next;
200
207
  try {
201
- // The run this wait follows, not the base under whatever derivation is live: a release swap mid-wait otherwise reads
202
- // not-started while the run goes on to admit this command's crawl (galaxy's n8n replays, 2026-10-03).
208
+ // The run this wait follows, until the repository has a record.
203
209
  next = await readOnboardingStatus(status.repository, status.baseCommit, token, status.onboardRunId);
204
210
  }
205
211
  catch (error) {
@@ -282,16 +288,16 @@ export function blockLines(block) {
282
288
  return lines;
283
289
  }
284
290
  export function formatOnboarding(status) {
285
- const lines = [chalk.dim(` Onboarding ${status.onboardRunId ?? '(none)'} of ${safe(status.repository)} at base ${status.baseCommit.slice(0, 12)}`)];
291
+ const lines = [chalk.dim(` Onboarding ${status.onboardRunId ?? '(none)'} of ${safe(status.repository)}, asked at base ${status.baseCommit.slice(0, 12)}`)];
286
292
  switch (status.state) {
287
293
  case 'ready':
288
- lines.push('The app is onboarded for this base.');
294
+ lines.push(readyLine(status));
289
295
  break;
290
296
  case 'onboarding':
291
297
  lines.push(onboardingStep(status));
292
298
  break;
293
299
  case 'not-started':
294
- lines.push('Onboarding has not started for this base. `haystack init` starts it.');
300
+ lines.push('Onboarding has not started for this repository. `haystack init` starts it.');
295
301
  break;
296
302
  case 'incomplete':
297
303
  lines.push(`Onboarding stopped before finishing${status.checkpoint?.error ? `: ${safe(status.checkpoint.error.message)}` : ''}.`
@@ -319,6 +325,20 @@ export function formatOnboarding(status) {
319
325
  lines.push('', ...unreviewed.map(note => chalk.yellow(`Note: ${safe(note)}`)));
320
326
  return lines.join('\n');
321
327
  }
328
+ /** Rule 2 (amendment 34): the repository's one setup, which base it was made at and how long ago, in one sentence. */
329
+ export function readyLine(status, now = Date.now()) {
330
+ if (status.recordBaseCommit === null || status.recordPublishedAt === null)
331
+ return 'The app is onboarded.';
332
+ return `The app is onboarded: its setup was made at base ${status.recordBaseCommit.slice(0, 12)} `
333
+ + `${age(now - Date.parse(status.recordPublishedAt))} and serves every base.`;
334
+ }
335
+ /** "just now", "5 minutes ago", "3 hours ago", "2 days ago". */
336
+ function age(ms) {
337
+ const minutes = Math.floor(Math.max(0, ms) / 60_000);
338
+ const [count, unit] = minutes < 1 ? [0, ''] : minutes < 60 ? [minutes, 'minute'] : minutes < 2_880 ? [Math.floor(minutes / 60), 'hour']
339
+ : [Math.floor(minutes / 1_440), 'day'];
340
+ return count === 0 ? 'just now' : `${count} ${unit}${count === 1 ? '' : 's'} ago`;
341
+ }
322
342
  /** Rule 17 (amendment 32): an onboarding the independent review skipped, as the sentence every result shows; none otherwise. */
323
343
  export function reviewNotes(review) {
324
344
  if (review?.verdict !== 'skipped')
@@ -397,7 +417,7 @@ export async function verifyAnswerCommand(questionId, choice, options) {
397
417
  }
398
418
  }
399
419
  /** Makes the coding agent's notes the repository's onboarding notes (rule 7, amendment 18), unless they are exactly these
400
- * already: 'unchanged' then, so running init again with the same notes starts no new onboarding. */
420
+ * already: 'unchanged' then, so running init again with the same notes changes no facts. */
401
421
  export async function writeOnboardingNotes(repository, notes, token) {
402
422
  for (let attempt = 1;; attempt += 1) {
403
423
  const facts = await readFacts(repository, token);
@@ -486,7 +486,7 @@ function printResult(result) {
486
486
  else if (crawl?.status === 'unavailable')
487
487
  console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
488
488
  else if (crawl?.status === 'onboarding') {
489
- console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); this change is built and frozen when it is ready.`
489
+ console.log(`The app is being onboarded (${crawl.onboardRunId}); this change is built and frozen when it is ready.`
490
490
  + ' Run `haystack verify` to check it.');
491
491
  }
492
492
  else if (crawl?.status === 'onboarding-blocked')
@@ -32,7 +32,7 @@ import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
32
32
  import { CRAWL_BRIEF_MAX_FUNCTIONS, CRAWL_BRIEF_MAX_GROUPS, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_IDEAS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_MAX_TEXT_CHARS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
33
33
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
34
34
  import { SPOT_WORDS, TERMINAL, VERDICT_WORDS, VERDICTS, currentStep, errorWords, headline, plural, reportedStatus, results, verifyReport, } from './crawl-report.js';
35
- import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, reviewNotes, standInGapNotes, waitForOnboarding, } from './verify-onboarding.js';
35
+ import { formatOnboarding, onboardingExitCode, readOnboardingStatus, readyLine, reportOnboardingState, reviewNotes, standInGapNotes, waitForOnboarding, } from './verify-onboarding.js';
36
36
  import { formatCapture, readPreVerifyCapture } from './capture-brief.js';
37
37
  /** A held read (waitAfter): the service's hold, then the time any read gets. */
38
38
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
@@ -660,11 +660,11 @@ async function submitCapture(capture, token, found) {
660
660
  line: `${before}, and ${found === null ? 'one' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
661
661
  }
662
662
  if (crawl.status === 'onboarding') {
663
- return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded for this base yet: `
663
+ return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded yet: `
664
664
  + `the service is preparing it (${crawl.onboardRunId}) and runs the crawl when it is ready.` };
665
665
  }
666
666
  if (crawl.status === 'onboarding-blocked') {
667
- return { runId: null, onboarding: 'onboarding-blocked', line: `${before}, and the app's onboarding for this base is blocked.` };
667
+ return { runId: null, onboarding: 'onboarding-blocked', line: `${before}, and the app's onboarding is blocked.` };
668
668
  }
669
669
  if (found === null) {
670
670
  return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
@@ -834,9 +834,12 @@ export function formatBrief(brief) {
834
834
  lines.push(` ${safe(change.changeKey)}: ${safe(change.reason)}`);
835
835
  }
836
836
  }
837
- lines.push('', 'Next: pick the ideas worth trying, add your own, and say what you were asked to do (the task in the user\'s words, not what your code does):', ' haystack verify --intent "<what you were asked to do>" --idea "<something to try>" [--idea ...]', 'Your ideas are explored first; the ideas above are explored in every crawl too.');
838
837
  return lines.join('\n');
839
838
  }
839
+ /** The handshake's question, asked after everything pre-verify shows. */
840
+ const STEER_LINES = ['', 'Next: pick the ideas worth trying, add your own, and say what you were asked to do (the task in the user\'s words, not what your code does):',
841
+ ' haystack verify --intent "<what you were asked to do>" --idea "<something to try>" [--idea ...]',
842
+ 'Your ideas are explored first; the ideas above are explored in every crawl too.'];
840
843
  /** `haystack pre-verify` (CRAWL-V1 amendment 18): the brief of the current change, before any crawl. It captures the checkout as
841
844
  * the turn-end hook does, prepares it when no run of that capture is under way or done (it never widens a run to a crawl), and
842
845
  * prints the run's brief. Exit codes: 0 with a brief, 2 when the run ended without one, 3 onboarding blocked, 1 otherwise. */
@@ -886,7 +889,7 @@ export async function preVerifyCommand(options) {
886
889
  process.exitCode = onboardingExitCode(onboarding);
887
890
  return;
888
891
  }
889
- note('The app is onboarded for this base.');
892
+ note(readyLine(onboarding));
890
893
  submitted = await submitCapture(capture, auth.token, found);
891
894
  note(submitted.line);
892
895
  }
@@ -920,6 +923,8 @@ export async function preVerifyCommand(options) {
920
923
  if (!options.json && captured !== null)
921
924
  for (const section of captured)
922
925
  console.log(`\n${formatCapture(section)}`);
926
+ if (!options.json && brief !== null)
927
+ console.log(STEER_LINES.join('\n'));
923
928
  if (brief === null && TERMINAL.has(reportedStatus(view)))
924
929
  process.exitCode = 2;
925
930
  }
@@ -981,9 +986,9 @@ export async function verifyCommand(options) {
981
986
  finishWithOnboarding(onboarding, options, change);
982
987
  return;
983
988
  }
984
- // Ready: the service admitted the waiting crawl when the record published; this
985
- // submission of the same capture finds it (or starts it, when a newer stop replaced it).
986
- note('The app is onboarded for this base.');
989
+ // Ready: the service admitted the waiting crawl when the record published (amendment 12:
990
+ // every waiting request is admitted); this submission of the same capture finds it.
991
+ note(readyLine(onboarding));
987
992
  submitted = await submitCapture(capture, auth.token, found);
988
993
  note(submitted.line);
989
994
  }
package/dist/index.js CHANGED
@@ -119,7 +119,9 @@ an hour). The file is JSON with any of three notes, each plain text:
119
119
  signIn how a person gets an account and signs in, and the kinds of users
120
120
  workflow what a signed-in user mainly does, step by step
121
121
  Leave out what you do not know. Onboarding still proves everything it takes
122
- from the notes. New notes start a new onboarding; the same notes again do not.
122
+ from the notes. The app is onboarded once: notes written before that feed its
123
+ onboarding (an onboarding that stopped on a question starts again with them);
124
+ after it, new notes start no onboarding.
123
125
 
124
126
  Every run also plans telemetry for the app: --app names it when the repository
125
127
  has several (init never picks one), --origin its production origins and
@@ -180,7 +182,8 @@ your crawl of that exact capture: the same code and the same change title (the
180
182
  title tells the crawl's judge what the change is). When there is none, or the
181
183
  one it finds was cancelled, stopped before finishing or is being cancelled, it
182
184
  submits the capture (the service starts the crawl, or runs the stopped one
183
- again), says so, and follows that crawl.
185
+ again), says so, and follows that crawl. When the stop hook already built and
186
+ froze this change, the crawl starts from that instead of building it again.
184
187
 
185
188
  The coding agent that made the change can say what it was asked to do (--intent:
186
189
  the task in the user's words, not a description of its code) and what to try (--idea, as many as it likes, or --ideas-file). Each idea is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.27.0",
3
+ "version": "0.27.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": {