@haystackeditor/cli 0.24.1 → 0.25.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.
Files changed (51) hide show
  1. package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
  2. package/dist/assets/capture/release.json +4 -0
  3. package/dist/assets/telemetry/runtime.cjs +829 -1254
  4. package/dist/capture/adapters/client-routes.js +383 -0
  5. package/dist/capture/adapters/django.js +134 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +74 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next-build.js +113 -0
  10. package/dist/capture/adapters/next.js +494 -0
  11. package/dist/capture/adapters/nuxt.js +199 -0
  12. package/dist/capture/adapters/rails.js +178 -0
  13. package/dist/capture/adapters/react-router.js +439 -0
  14. package/dist/capture/adapters/sveltekit.js +109 -0
  15. package/dist/capture/adapters/types.js +4 -0
  16. package/dist/capture/adapters/vite.js +135 -0
  17. package/dist/capture/app-config.js +107 -0
  18. package/dist/capture/consent.js +127 -0
  19. package/dist/capture/csp.js +332 -0
  20. package/dist/capture/html.js +74 -0
  21. package/dist/capture/js-ast.js +400 -0
  22. package/dist/capture/manifest.js +95 -0
  23. package/dist/capture/project.js +177 -0
  24. package/dist/capture/route-pattern.js +119 -0
  25. package/dist/capture/script-release.js +47 -0
  26. package/dist/capture/tag.js +74 -0
  27. package/dist/capture/url-rewrites.js +232 -0
  28. package/dist/capture-step.js +56 -0
  29. package/dist/commands/capture-brief.js +92 -0
  30. package/dist/commands/capture-contract.js +46 -0
  31. package/dist/commands/capture-manifest.js +86 -0
  32. package/dist/commands/init-capture.js +426 -0
  33. package/dist/commands/init-telemetry.js +1028 -0
  34. package/dist/commands/init.js +78 -5
  35. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  36. package/dist/commands/server-telemetry-contract.js +127 -0
  37. package/dist/commands/telemetry-token.js +238 -0
  38. package/dist/commands/telemetry.d.ts +161 -8
  39. package/dist/commands/telemetry.js +940 -158
  40. package/dist/commands/verify-onboarding.js +21 -1
  41. package/dist/commands/verify.js +56 -9
  42. package/dist/index.js +85 -6
  43. package/dist/schema.js +2 -2
  44. package/dist/telemetry/next-loader.cjs +66 -9
  45. package/dist/telemetry/next.d.ts +11 -3
  46. package/dist/telemetry/next.js +95 -15
  47. package/dist/telemetry/typed-source.d.ts +47 -0
  48. package/dist/telemetry/typed-source.js +379 -0
  49. package/package.json +4 -2
  50. package/schemas/init.v1.json +63 -4
  51. package/schemas/pre-verify.v1.json +60 -3
@@ -28,6 +28,7 @@ const STATES = ['ready', 'onboarding', 'blocked', 'incomplete', 'cancelled', 'no
28
28
  const BLOCK_KINDS = ['not-enabled', 'no-web-app', 'unsupported', 'question', 'outside-credential',
29
29
  'outside-dependency', 'evidence-too-large', 'budget', 'proof-failed'];
30
30
  const STAGE_STATUSES = ['pending', 'running', 'ok', 'reused', 'failed', 'blocked'];
31
+ const SERVICE_ENGINES = ['postgres', 'mysql', 'redis', 'mongodb', 'convex'];
31
32
  const STAGE_WORDS = {
32
33
  inventory: 'reading the repository', plan: 'planning how to run the app', runtime: 'building its runtime',
33
34
  boot: 'starting the app', data: 'preparing its data', accounts: 'creating test accounts', workflow: 'proving a workflow',
@@ -99,6 +100,15 @@ export function parseOnboardingStatus(value, expected) {
99
100
  invalid('its stand-ins');
100
101
  if (!(value.worldMemoryMib === null || (typeof value.worldMemoryMib === 'number' && Number.isInteger(value.worldMemoryMib))))
101
102
  invalid('its world memory');
103
+ if (!Array.isArray(value.services) || !value.services.every(service => isRecord(service) && typeof service.id === 'string'
104
+ && SERVICE_ENGINES.includes(service.engine)))
105
+ invalid('its services');
106
+ const review = value.review;
107
+ if (!(review === null || (isRecord(review) && (review.verdict === 'accept'
108
+ || (review.verdict === 'skipped' && typeof review.reason === 'string' && review.reason.length > 0)))))
109
+ invalid('its review');
110
+ if (value.state !== 'ready' && review !== null)
111
+ invalid('its review');
102
112
  if (value.block !== null)
103
113
  parseOnboardingBlock(value.block);
104
114
  const checkpoint = value.checkpoint;
@@ -307,8 +317,18 @@ export function formatOnboarding(status) {
307
317
  const outside = [...status.standIns, ...status.unsetProviders, ...standInGapNotes(status.standInGaps)];
308
318
  if (outside.length)
309
319
  lines.push('', ...outside.map(note => chalk.yellow(`Note: ${safe(note)}`)));
320
+ const unreviewed = reviewNotes(status.review);
321
+ if (unreviewed.length)
322
+ lines.push('', ...unreviewed.map(note => chalk.yellow(`Note: ${safe(note)}`)));
310
323
  return lines.join('\n');
311
324
  }
325
+ /** Rule 17 (amendment 32): an onboarding the independent review skipped, as the sentence every result shows; none otherwise. */
326
+ export function reviewNotes(review) {
327
+ if (review?.verdict !== 'skipped')
328
+ return [];
329
+ return [`This onboarding was NOT reviewed: the independent review that checks the setup did not cheat is turned off for this `
330
+ + `team (${review.reason}).`];
331
+ }
312
332
  /** Rule 15(g), amendment 3: a stand-in gap as the sentence every result shows. */
313
333
  export function standInGapNotes(gaps) {
314
334
  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.`);
@@ -336,7 +356,7 @@ function repositoryOf(value) {
336
356
  const origin = resolveOriginRepository(gitRoot);
337
357
  return `${origin.owner}/${origin.repository}`;
338
358
  }
339
- async function readFacts(repository, token) {
359
+ export async function readFacts(repository, token) {
340
360
  const response = await gatewayFetch(`${FACTS_PATH}?repository=${encodeURIComponent(repository)}`, token);
341
361
  if (!response.ok)
342
362
  throw await classifyHttpError(response, `Haystack API ${FACTS_PATH}`);
@@ -31,7 +31,8 @@ import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSi
31
31
  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
- import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, standInGapNotes, waitForOnboarding } from './verify-onboarding.js';
34
+ import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, reviewNotes, standInGapNotes, waitForOnboarding, } from './verify-onboarding.js';
35
+ import { formatCapture, readPreVerifyCapture } from './capture-brief.js';
35
36
  const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
36
37
  /** A held read (waitAfter): the service's hold, then the time any read gets. */
37
38
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
@@ -256,13 +257,23 @@ function checkBrief(value) {
256
257
  // Amendment 19: each production row names a function the brief holds.
257
258
  const production = value.production;
258
259
  const line = (item) => isCount(item) && item >= 1;
260
+ const shares = (item) => records(item, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, row => text(row.category)
261
+ && typeof row.share === 'number' && Number.isFinite(row.share) && row.share >= 0);
262
+ // CAPTURE-V1 rule 9(d): sampler-5 estimates. A worker from before them sends none; that reads as none.
263
+ const estimates = (fn) => fn.estimates === undefined
264
+ || (isCount(fn.estimatesOmitted) && records(fn.estimates, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, estimate => line(estimate.line)
265
+ && (estimate.kind === 'branch' || estimate.kind === 'setting' || estimate.kind === 'value') && text(estimate.what)
266
+ && isCount(estimate.calls) && isCount(estimate.classified) && typeof estimate.partial === 'boolean' && isCount(estimate.partialCalls)
267
+ && shares(estimate.shares) && records(estimate.fields, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, field => text(field.field)
268
+ && isCount(field.objectCalls) && shares(field.shares))));
259
269
  if (production !== undefined && (!isRecord(production) || !isCount(production.windowDays) || !isCount(production.payloads)
260
270
  || !text(production.newestAt, true) || typeof production.partial !== 'boolean' || !records(production.functions, CRAWL_BRIEF_MAX_FUNCTIONS, fn => isCount(fn.index)
261
271
  && fn.index < value.functions.length && isCount(fn.branchesOmitted) && isCount(fn.valuesOmitted)
262
272
  && records(fn.branches, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, branch => line(branch.line) && isCount(branch.whenTrue)
263
273
  && isCount(branch.whenFalse) && typeof branch.capped === 'boolean')
264
274
  && records(fn.values, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, observed => line(observed.line) && text(observed.what)
265
- && records(observed.shapes, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, shape => text(shape.shape) && isCount(shape.count))))))
275
+ && records(observed.shapes, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, shape => text(shape.shape) && isCount(shape.count)))
276
+ && estimates(fn))))
266
277
  invalid('its brief');
267
278
  }
268
279
  function checkManifest(value, view) {
@@ -742,7 +753,7 @@ async function submitCapture(capture, token, found) {
742
753
  }
743
754
  if (crawl.status === 'unavailable') {
744
755
  return { runId: null, onboarding: null,
745
- line: `${before}, and ${found === null ? 'none' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
756
+ line: `${before}, and ${found === null ? 'one' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
746
757
  }
747
758
  if (crawl.status === 'onboarding') {
748
759
  return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded for this base yet: `
@@ -833,6 +844,32 @@ async function waitForBrief(first, identity, token) {
833
844
  process.removeListener('SIGINT', interrupted);
834
845
  }
835
846
  }
847
+ /** A share as the brief says it: rounded, a share too small to round still visible, and an estimate past 1 (sampling
848
+ * error) said as about all of them; the data keeps the estimate itself. */
849
+ function percent(share) {
850
+ return share > 0 && share < 0.005 ? '<1%' : `~${Math.round(Math.min(share, 1) * 100)}%`;
851
+ }
852
+ const SHOWN_SHARES = 8;
853
+ /** CAPTURE-V1 rule 9(d): "undefined ~12% (4,180 classified of 41,200)" per site, of observed calls, never users. */
854
+ function describeEstimate(estimate) {
855
+ const number = (value) => value.toLocaleString('en-US');
856
+ const sample = `${number(estimate.classified)} classified of ${number(estimate.calls)}`
857
+ + (estimate.partial ? '; partial: classification was skipped in every window, so these are shares of the classified calls'
858
+ : estimate.partialCalls > 0 ? `; ${number(estimate.partialCalls)} calls in windows that skipped classification not included` : '');
859
+ const listed = (shares) => shares.slice(0, SHOWN_SHARES)
860
+ .map(row => `${safe(row.category)} ${percent(row.share)}`).join(', ')
861
+ + (shares.length > SHOWN_SHARES ? `, ${shares.length - SHOWN_SHARES} more (in --json)` : '');
862
+ if (estimate.kind === 'branch') {
863
+ const whenTrue = estimate.shares.find(row => row.category === 'true')?.share ?? 0;
864
+ return [` in production: line ${estimate.line} was true in ${percent(whenTrue)} of observed calls (${sample})`];
865
+ }
866
+ const lines = [` in production: line ${estimate.line} ${safe(estimate.what)}${estimate.kind === 'setting' ? ' (an approved setting)' : ''}`
867
+ + ` in observed calls: ${listed(estimate.shares)} (${sample})`];
868
+ for (const field of estimate.fields) {
869
+ lines.push(` .${safe(field.field)} where it was an object (~${number(field.objectCalls)} calls): ${listed(field.shares)}`);
870
+ }
871
+ return lines;
872
+ }
836
873
  /** The brief as a coding agent reads it: what the change touches, then Haystack's own ideas, then how to hand back intent and
837
874
  * ideas. Every function is in --json; the text shows the changed ones and those one step away, and says how many more. */
838
875
  export function formatBrief(brief) {
@@ -860,12 +897,18 @@ export function formatBrief(brief) {
860
897
  + observed.shapes.map(shape => `${safe(shape.shape)} (${shape.count})`).join(', '));
861
898
  if (row.valuesOmitted)
862
899
  lines.push(` and ${plural(row.valuesOmitted, 'more observed value')} (in --json)`);
900
+ for (const estimate of row.estimates ?? [])
901
+ lines.push(...describeEstimate(estimate));
902
+ if (row.estimatesOmitted)
903
+ lines.push(` and ${plural(row.estimatesOmitted, 'more estimated site')} (in --json)`);
863
904
  });
864
905
  if (brief.production) {
865
906
  const shownSeen = brief.production.functions.filter(row => brief.functions[row.index].distance <= 1).length;
866
907
  lines.push(` Production telemetry: the last ${brief.production.windowDays} days, newest ${safe(brief.production.newestAt)};`
867
908
  + ` ${plural(brief.production.functions.length, 'function')} seen running (${shownSeen} above). Counts marked + are lower bounds`
868
- + `${brief.production.partial ? '; the read stopped at its budget, so every count is a lower bound' : ''}.`);
909
+ + `${brief.production.partial ? '; the read stopped at its budget, so every count is a lower bound' : ''}.`
910
+ + `${brief.production.functions.some(row => (row.estimates ?? []).length > 0)
911
+ ? ' Shares are estimates of the observed calls at each site (heavier users make more calls), never of users.' : ''}`);
869
912
  }
870
913
  else
871
914
  lines.push(' No production telemetry for this repository: set it up to see how this code runs for real users.');
@@ -887,7 +930,7 @@ export function formatBrief(brief) {
887
930
  lines.push(` ${safe(change.changeKey)}: ${safe(change.reason)}`);
888
931
  }
889
932
  }
890
- lines.push('', 'Next: pick the ideas worth trying, add your own, and say what the change is for:', ' haystack verify --intent "<what the change is for>" --idea "<something to try>" [--idea ...]', 'Your ideas are explored first; the ideas above are explored in every crawl too.');
933
+ 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.');
891
934
  return lines.join('\n');
892
935
  }
893
936
  /** `haystack pre-verify` (CRAWL-V1 amendment 18): the brief of the current change, before any crawl. It captures the checkout as
@@ -901,10 +944,10 @@ export async function preVerifyCommand(options) {
901
944
  const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'prepare', repositoryOverride(options.repo));
902
945
  const request = capture.derivation.request;
903
946
  const change = checkedChange(request.baseCommit, request.workCommit);
904
- const finish = (onboarding, view) => {
947
+ const finish = (onboarding, view, captured = null) => {
905
948
  if (options.json)
906
949
  process.stdout.write(`${JSON.stringify(withSchema('pre-verify', { change, onboarding,
907
- run: view === null ? null : { runId: view.runId, status: view.status }, brief: view === null ? null : briefOf(view) }), null, 2)}\n`);
950
+ run: view === null ? null : { runId: view.runId, status: view.status }, brief: view === null ? null : briefOf(view), capture: captured }), null, 2)}\n`);
908
951
  };
909
952
  if (!request.crawl) {
910
953
  note(`Nothing to check: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
@@ -954,8 +997,10 @@ export async function preVerifyCommand(options) {
954
997
  if (options.wait !== false && briefOf(view) === null && !TERMINAL.has(reportedStatus(view)))
955
998
  view = await waitForBrief(view, identity, auth.token);
956
999
  const brief = briefOf(view);
1000
+ // CAPTURE-V1 rule 7b: the routes the change touches, with their share of captured sessions; null without capture set up.
1001
+ const captured = await readPreVerifyCapture(findGitRoot(), identity.repository, auth.token, [...new Set([...(brief?.functions.map(fn => fn.file) ?? []), ...change.files])], new Date());
957
1002
  if (options.json)
958
- finish(onboarding, view);
1003
+ finish(onboarding, view, captured);
959
1004
  else if (brief !== null)
960
1005
  console.log(formatBrief(brief));
961
1006
  else if (!TERMINAL.has(reportedStatus(view)))
@@ -968,6 +1013,8 @@ export async function preVerifyCommand(options) {
968
1013
  + (view.status === 'prepared' ? 'Run `haystack verify` to crawl the change; its results carry the brief.'
969
1014
  : view.status === 'completed' ? 'It was checked before briefs existed and is not run again: change anything in the checkout and run `haystack pre-verify` again.'
970
1015
  : 'Run `haystack pre-verify` again to prepare it again.'));
1016
+ if (!options.json && captured !== null)
1017
+ console.log(`\n${formatCapture(captured)}`);
971
1018
  if (brief === null && TERMINAL.has(reportedStatus(view)))
972
1019
  process.exitCode = 2;
973
1020
  }
@@ -1047,7 +1094,7 @@ export async function verifyCommand(options) {
1047
1094
  else {
1048
1095
  console.log(formatCrawl(view));
1049
1096
  const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? []), ...(onboarding?.standIns ?? []),
1050
- ...(onboarding?.unsetProviders ?? []), ...standInGapNotes(onboarding?.standInGaps ?? [])];
1097
+ ...(onboarding?.unsetProviders ?? []), ...standInGapNotes(onboarding?.standInGaps ?? []), ...reviewNotes(onboarding?.review ?? null)];
1051
1098
  if (notes.length)
1052
1099
  console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
1053
1100
  }
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ import { lazy } from './lazy.js';
31
31
  // the startup path of every invocation, including --version and --help.
32
32
  const statusCommand = lazy(() => import('./commands/status.js'), 'statusCommand');
33
33
  const initCommand = lazy(() => import('./commands/init.js'), 'initCommand');
34
+ const captureManifestCommand = lazy(() => import('./commands/capture-manifest.js'), 'captureManifestCommand');
34
35
  const authListCommand = lazy(() => import('./commands/login.js'), 'authListCommand');
35
36
  const authUseCommand = lazy(() => import('./commands/login.js'), 'authUseCommand');
36
37
  const loginCommand = lazy(() => import('./commands/login.js'), 'loginCommand');
@@ -72,6 +73,8 @@ const prReadCommand = lazy(() => import('./commands/pr.js'), 'prReadCommand');
72
73
  const inboxListCommand = lazy(() => import('./commands/inbox.js'), 'inboxListCommand');
73
74
  const askHaystackCommand = lazy(() => import('./commands/ask.js'), 'askHaystackCommand');
74
75
  const telemetryInstrumentCommand = lazy(() => import('./commands/telemetry.js'), 'telemetryInstrumentCommand');
76
+ const telemetrySettingsCommand = lazy(() => import('./commands/telemetry.js'), 'telemetrySettingsCommand');
77
+ const telemetryTokenCommand = lazy(() => import('./commands/telemetry-token.js'), 'telemetryTokenCommand');
75
78
  const setupCommand = lazy(() => import('./commands/setup.js'), 'setupCommand');
76
79
  const schemaCommand = lazy(() => import('./commands/schema-cmd.js'), 'schemaCommand');
77
80
  const listSchemas = lazy(() => import('./commands/schema-cmd.js'), 'listSchemas');
@@ -148,6 +151,10 @@ program
148
151
  .description('Set this repository up for `haystack verify` and start onboarding the app')
149
152
  .option('-y, --yes', 'Make the changes without asking')
150
153
  .option('--notes <file>', 'A JSON file (- for stdin) of what you know about running the app; see below')
154
+ .option('--app <dir>', 'The app whose telemetry init sets up, when the repository has several; see below')
155
+ .option('--origin <url>', 'A production origin of the app (repeat for several): browser capture accepts events only from these', (value, previous = []) => [...previous, value])
156
+ .option('--consent <choice>', 'How the site asks visitors for consent: its consent platform, or not-required when your user decided so')
157
+ .option('--url-rewrites <answer>', 'When init asks: none, or hidden-segment:<name> when the app\'s middleware adds a leading [<name>] segment to page paths')
151
158
  .option('--json', 'The result as one JSON document on stdout (see `haystack schema init`)')
152
159
  .addHelpText('after', `
153
160
  There is no setup file: Haystack works out how to run the app from what the
@@ -175,6 +182,28 @@ an hour). The file is JSON with any of three notes, each plain text:
175
182
  Leave out what you do not know. Onboarding still proves everything it takes
176
183
  from the notes. New notes start a new onboarding; the same notes again do not.
177
184
 
185
+ Every run also plans telemetry for the app: --app names it when the repository
186
+ has several (init never picks one), --origin its production origins and
187
+ --consent the consent choice; until they are given, only telemetry waits and
188
+ its report says what to ask. Server telemetry: a Next.js app's next.config
189
+ gains the withHaystackTelemetry wrapper in its own export form; a Node server
190
+ whose build ends in a \`tsc\` compile gains \`haystack telemetry instrument\`
191
+ after it; the CLI becomes a checked, exact-version dependency through the
192
+ app's package manager; \`haystack telemetry settings --propose\` writes a
193
+ settings proposal that only your user approves (\`--approve\`). Each is a
194
+ change shown first like the others, and a failed install restores every file.
195
+ init never mints the ingest token: a repository admin runs
196
+ \`haystack telemetry token\` themselves. \`--json\` reports \`telemetry\`
197
+ (each part installed, manual or unsupported, and what Haystack last received)
198
+ and \`telemetrySteps\` (what the person does next, including the off switch
199
+ HAYSTACK_TELEMETRY=0).
200
+
201
+ Browser capture: --consent is cookiebot, onetrust:<category id>, not-required
202
+ or manual. init adds the self-hosted tag, its consent wiring and the build
203
+ step that publishes the route manifest (\`haystack capture manifest\`, which
204
+ never fails the build), registers the app for its key only with --yes, and
205
+ reports the Content-Security-Policy lines the tag needs without editing them.
206
+
178
207
  Exit codes:
179
208
  0 set up: onboarding is running or the app is ready
180
209
  1 the command failed
@@ -245,7 +274,7 @@ const verify = program
245
274
  .option('--no-wait', 'Print the crawl\'s current state and return without waiting')
246
275
  .option('--minutes <n>', 'How long the crawl may take, 1-30 (default: .haystack.json crawl.minutes, else 3)')
247
276
  .addOption(new Option('--pool <pool>', 'Where the crawl\'s clones are created: fleet, freestyle or auto (default auto)').hideHelp())
248
- .option('--intent <sentence>', 'What your change is for, in a sentence: the judge reads it so a difference you meant is not called a bug')
277
+ .option('--intent <sentence>', 'What you were asked to do, in the user\'s words (the task, not what your code does): the judge checks the app against it')
249
278
  .option('--idea <text>', 'Something to try in the app, said as you would to a tester (repeat for more); each is explored first', (value, previous = []) => [...previous, value])
250
279
  .option('--ideas-file <path>', 'A JSON array of more ideas')
251
280
  .option('--json', 'The crawl as one JSON document on stdout (see `haystack schema verify`)')
@@ -259,8 +288,8 @@ one it finds was cancelled, stopped before finishing or is being cancelled, it
259
288
  submits the capture (the service starts the crawl, or runs the stopped one
260
289
  again), says so, and follows that crawl.
261
290
 
262
- The coding agent that made the change can say what it is for (--intent) and
263
- what to try (--idea, as many as it likes, or --ideas-file). Each idea is
291
+ The coding agent that made the change can say what it was asked to do (--intent:
292
+ 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
264
293
  explored before anything else, and the answer says what became of it: tried,
265
294
  could not try (and why), or not finished in time. Other ideas make another
266
295
  crawl of the same code; the same ones again follow the same crawl.
@@ -318,10 +347,12 @@ same way, builds and prepares the change when nothing has yet (the turn-end
318
347
  hook usually has), and prints its brief: the functions the change touches, the
319
348
  changed ones and those one step away first (every one is in --json), and
320
349
  Haystack's own ideas of what could break, each with what to set up, what to do
321
- and what to watch. It never starts a crawl.
350
+ and what to watch. With capture set up (.haystack/capture.json), it also shows
351
+ the routes the change touches with their share of real users' sessions. It
352
+ never starts a crawl.
322
353
 
323
- Then hand back what the change is for and what to try:
324
- haystack verify --intent "<what the change is for>" --idea "<something to try>"
354
+ Then hand back what you were asked to do and what to try:
355
+ haystack verify --intent "<what you were asked to do, in the user's words>" --idea "<something to try>"
325
356
 
326
357
  Exit codes: 0 with a brief; 2 when the run ended without one; 3 when the app's
327
358
  onboarding is blocked; 1 when the command failed.
@@ -970,6 +1001,54 @@ Example:
970
1001
  includeSymbolsFile: options.includeSymbols,
971
1002
  includeSymbols: undefined,
972
1003
  }), options.json));
1004
+ telemetry
1005
+ .command('settings')
1006
+ .description('Propose .haystack/telemetry-settings.json: the settings whose values telemetry may record as themselves')
1007
+ .option('--propose', 'Write candidates read from the typed source to .haystack/telemetry-settings.proposed.json for review')
1008
+ .option('--approve', 'Show the reviewed proposal\'s change to .haystack/telemetry-settings.json and make it the allowlist')
1009
+ .option('--dry-run', 'Print what would be written without writing')
1010
+ .option('--json', 'Machine-readable proposal')
1011
+ .addHelpText('after', `
1012
+ Server telemetry records every value as its shape and size only. A setting
1013
+ (a parameter or binding whose TypeScript type is a closed set of literals:
1014
+ 'light' | 'dark', an enum, a boolean, an as-const array) is recorded as its
1015
+ literal only when it is in .haystack/telemetry-settings.json. --propose writes
1016
+ every candidate it finds to .haystack/telemetry-settings.proposed.json (approved
1017
+ entries kept as they are), refusing names and literals that look like identity
1018
+ or secrets (id, email, name, token, password, key, secret, address, phone).
1019
+ A proposal approves nothing: review it, then --approve shows the change to
1020
+ .haystack/telemetry-settings.json, writes it, and you commit it.
1021
+ `)
1022
+ .action((options) => runPublicCommand(() => telemetrySettingsCommand(options), options.json));
1023
+ telemetry
1024
+ .command('token')
1025
+ .description('Mint the server-telemetry ingest token for a repository\'s production (run it yourself, in your own terminal)')
1026
+ .option('--repo <owner/name>', 'GitHub repository whose production sends telemetry')
1027
+ .option('--rotate', 'Replace the active token; the old one stops working at once')
1028
+ .option('--out <file>', 'Where to write the token (default ~/.haystack/telemetry-ingest-tokens/<owner>__<name>-production.token)')
1029
+ .option('--account <login>', 'Use a specific saved Haystack account')
1030
+ .addHelpText('after', `
1031
+ It needs admin or maintain permission on the repository and an interactive
1032
+ terminal: a coding agent or a script never runs it. The token is generated
1033
+ here and written to a file only you can read; it is never printed, and
1034
+ Haystack keeps only its SHA-256. Running it again mints nothing while a token
1035
+ is active and says whether that file holds it. Then set HAYSTACK_TELEMETRY=1,
1036
+ HAYSTACK_TELEMETRY_ENDPOINT and HAYSTACK_TELEMETRY_TOKEN in production's server
1037
+ environment (never a public or client-side variable).
1038
+
1039
+ Exit codes: 0 minted, or the file holds the active token; 1 failed;
1040
+ 2 declined at the prompt; 3 another token is active and this file does not
1041
+ hold it (--rotate replaces it).
1042
+ `)
1043
+ .action((options) => runPublicCommand(() => telemetryTokenCommand(options)));
1044
+ program.command('capture').description('Real users\' journeys and devices: the tag `haystack init` adds')
1045
+ .command('manifest')
1046
+ .description('Publish the app\'s route manifest into its static output (the build step `haystack init` adds)')
1047
+ .option('--app <dir>', 'The app directory (default: the current directory)')
1048
+ .option('--rails-routes <file>', 'Rails: the routes file the rake task wrote')
1049
+ .option('--adapter <id>', 'The framework adapter to use instead of the one init recorded in .haystack/capture.json')
1050
+ .option('--json', 'The release, counts and files written as JSON')
1051
+ .action((options) => runPublicCommand(() => captureManifestCommand(options), options.json));
973
1052
  // ─── db ──────────────────────────────────────────────────────────────────────
974
1053
  const dbProgram = program
975
1054
  .command('db')
package/dist/schema.js CHANGED
@@ -21,10 +21,10 @@ export const SCHEMA_VERSIONS = {
21
21
  'cloud-verifier': '1.0.0',
22
22
  'case-batch': '1.0.1',
23
23
  verify: '1.0.4',
24
- 'pre-verify': '1.0.1',
24
+ 'pre-verify': '1.0.2',
25
25
  'verify-answer': '1.0.1',
26
26
  'verify-onboarding': '1.0.0',
27
- init: '1.0.1',
27
+ init: '1.0.2',
28
28
  login: '1.0.0',
29
29
  error: '1.0.0',
30
30
  };
@@ -6,9 +6,12 @@
6
6
  * require() it; the instrumenter itself is ESM and is imported on first use.
7
7
  *
8
8
  * Per module: leave it alone unless it is git-tracked repository source
9
- * outside node_modules; otherwise instrument it at its repository path and
10
- * record its sites in this build's session directory for the post-compile
11
- * step. A module that is not instrumented is returned byte-for-byte.
9
+ * outside node_modules (and not middleware); otherwise instrument it at its
10
+ * repository path and record its sites in this build's session directory for
11
+ * the post-compile step. A module that is not instrumented is returned
12
+ * byte-for-byte with the map it came with. Rule 13(b): an instrumented module's
13
+ * map is composed through the previous loader's, and nothing this loader does
14
+ * can fail the build.
12
15
  */
13
16
  const node_child_process_1 = require("node:child_process");
14
17
  const node_crypto_1 = require("node:crypto");
@@ -42,18 +45,44 @@ function trackedFiles(sourceRoot) {
42
45
  }
43
46
  return tracked;
44
47
  }
45
- async function instrument(context, source, options) {
48
+ /** Middleware (`middleware.*`, and Next 16's `proxy.*`) at the project root or in `src/`. Next may compile it for the
49
+ * Node runtime, where the bundler's own conditions would let it through; rule 13(b) says it is never instrumented. */
50
+ function isMiddleware(resourcePath, projectDirectory) {
51
+ const fromProject = (0, node_path_1.relative)(projectDirectory, resourcePath).split(node_path_1.sep).join('/');
52
+ const stem = fromProject.replace(/\.[cm]?[jt]sx?$/, '');
53
+ return stem !== fromProject && ['middleware', 'src/middleware', 'proxy', 'src/proxy'].includes(stem);
54
+ }
55
+ /** A previous loader's map: webpack hands an object or its JSON text. */
56
+ function inputMap(sourceMap) {
57
+ if (typeof sourceMap === 'string') {
58
+ try {
59
+ const parsed = JSON.parse(sourceMap);
60
+ return parsed && typeof parsed === 'object' ? parsed : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ return sourceMap && typeof sourceMap === 'object' ? sourceMap : null;
67
+ }
68
+ async function instrument(context, source, sourceMap, options) {
46
69
  const relativePath = (0, node_path_1.relative)(options.sourceRoot, context.resourcePath);
47
70
  if (!relativePath || relativePath.startsWith('..') || (0, node_path_1.isAbsolute)(relativePath))
48
71
  return null;
49
72
  const sourcePath = relativePath.split(node_path_1.sep).join('/');
50
- if (sourcePath.split('/').includes('node_modules') || !trackedFiles(options.sourceRoot).has(sourcePath)) {
73
+ if (sourcePath.split('/').includes('node_modules') ||
74
+ (options.tracked && !trackedFiles(options.sourceRoot).has(sourcePath))) {
51
75
  return null;
52
76
  }
77
+ if (isMiddleware(context.resourcePath, options.projectDirectory))
78
+ return null;
53
79
  const result = (await loadTelemetry()).instrumentBundledModule(source, {
54
80
  sourcePath,
81
+ absolutePath: context.resourcePath,
82
+ sourceRoot: options.sourceRoot,
55
83
  controlChannel: options.controlChannel,
56
84
  runtimeIntegrity: options.runtimeIntegrity,
85
+ inputSourceMap: inputMap(sourceMap),
57
86
  });
58
87
  if (result.kind === 'unchanged')
59
88
  return null;
@@ -68,9 +97,15 @@ async function instrument(context, source, options) {
68
97
  // ships unchanged and adds nothing to the manifest.
69
98
  if (result.sites.length === 0)
70
99
  return null;
71
- const record = { sourcePath, probes: result.probes, sites: result.sites };
100
+ const record = {
101
+ sourcePath,
102
+ probes: result.probes,
103
+ sites: result.sites,
104
+ typed: result.typed,
105
+ settings: result.settings,
106
+ };
72
107
  writeSessionRecord(context, options, MODULES_DIRECTORY, sourcePath, source, record);
73
- return result.code;
108
+ return { code: result.code, map: result.map };
74
109
  }
75
110
  function writeSessionRecord(context, options, directoryName, sourcePath, source, record) {
76
111
  // The record must be written by every build that ships this module, so a
@@ -86,8 +121,30 @@ function writeSessionRecord(context, options, directoryName, sourcePath, source,
86
121
  (0, node_fs_1.writeFileSync)(temporary, JSON.stringify(record));
87
122
  (0, node_fs_1.renameSync)(temporary, (0, node_path_1.join)(directory, `${name}.json`));
88
123
  }
124
+ function load(context, source, sourceMap) {
125
+ const callback = context.async();
126
+ let options;
127
+ try {
128
+ options = context.getOptions();
129
+ }
130
+ catch {
131
+ callback(null, source, sourceMap);
132
+ return;
133
+ }
134
+ instrument(context, source, sourceMap, options).then(result => result === null ? callback(null, source, sourceMap) : callback(null, result.code, result.map ?? undefined), (error) => {
135
+ // Ours failed (git, the transform, the session directory): the module ships exactly as it came.
136
+ try {
137
+ const sourcePath = (0, node_path_1.relative)(options.sourceRoot, context.resourcePath).split(node_path_1.sep).join('/');
138
+ const reason = String(error instanceof Error ? error.message : error).split('\n')[0].slice(0, 300);
139
+ writeSessionRecord(context, options, UNPARSED_DIRECTORY, sourcePath, source, { sourcePath, reason });
140
+ }
141
+ catch {
142
+ // The report is best effort; the build is not.
143
+ }
144
+ callback(null, source, sourceMap);
145
+ });
146
+ }
89
147
  function haystackTelemetryNextLoader(source, sourceMap) {
90
- const callback = this.async();
91
- instrument(this, source, this.getOptions()).then(code => code === null ? callback(null, source, sourceMap) : callback(null, code), (error) => callback(error instanceof Error ? error : new Error(String(error))));
148
+ load(this, source, sourceMap);
92
149
  }
93
150
  module.exports = haystackTelemetryNextLoader;
@@ -1,8 +1,13 @@
1
- import { type FileProbeCounts, type InstrumentedSite } from '../commands/telemetry.js';
1
+ import { type BundledSettingsRecord, type BundledSourceIdentity, type FileProbeCounts, type InstrumentedSite } from '../commands/telemetry.js';
2
2
  /** Options the config hands every loader invocation; JSON for Turbopack. */
3
3
  export interface NextTelemetryLoaderOptions {
4
- /** Repository root (git top level); every site path is relative to it. */
4
+ /** Repository root (git top level); every site path is relative to it. Without git: the Next project. */
5
5
  sourceRoot: string;
6
+ /** Instrument only git-tracked files (false in a build without git: every non-node_modules source under sourceRoot). */
7
+ tracked: boolean;
8
+ sourceIdentity: BundledSourceIdentity;
9
+ /** The Next project (where next.config is): its middleware is never instrumented. */
10
+ projectDirectory: string;
6
11
  /** This build's hand-off directory between loaders and the post-compile step. */
7
12
  sessionDirectory: string;
8
13
  controlChannel: string;
@@ -13,8 +18,11 @@ export interface NextTelemetryModuleRecord {
13
18
  sourcePath: string;
14
19
  probes: FileProbeCounts;
15
20
  sites: InstrumentedSite[];
21
+ /** Rule 9(b)/(c): read with its types (declared fields, setting literals); false: shape-only. */
22
+ typed: boolean;
23
+ settings: BundledSettingsRecord;
16
24
  }
17
- /** A server module shipped uninstrumented because Babel could not parse it. */
25
+ /** A server module shipped uninstrumented because Babel could not parse it or the transform failed. */
18
26
  export interface NextTelemetryUnparsedRecord {
19
27
  sourcePath: string;
20
28
  reason: string;