@haystackeditor/cli 0.23.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ function loadBabel() {
18
18
  // @babel/core 8 is ESM-only. require() of ESM works from Node 22.12, but 22.12
19
19
  // prints an ExperimentalWarning for it, so the command path loads Babel with
20
20
  // import() first and loadBabel() then reuses that module.
21
- async function preloadBabel() {
21
+ export async function preloadBabel() {
22
22
  babelModule ??= await import('@babel/core');
23
23
  }
24
24
  const INSTRUMENTED_MARKER = '__haystack_telemetry_instrumented_v1__';
@@ -250,7 +250,13 @@ function runtimeControlDeclarations(t, controlName, helperName, specifier, modul
250
250
  return [
251
251
  t.variableDeclaration('const', [t.variableDeclarator(t.identifier(controlName), t.callExpression(t.functionExpression(null, [], t.blockStatement([
252
252
  t.tryStatement(t.blockStatement([
253
- t.variableDeclaration('const', [t.variableDeclarator(t.identifier('processHost'), t.callExpression(t.identifier('require'), [t.stringLiteral('node:process')]))]),
253
+ t.variableDeclaration('const', [t.variableDeclarator(t.identifier('processHost'),
254
+ // A bundled module must not name node:process: the bundler
255
+ // would resolve it as a module of its own. The global is the
256
+ // same object in a Node server.
257
+ moduleType === 'bundled'
258
+ ? t.memberExpression(t.identifier('globalThis'), t.identifier('process'))
259
+ : t.callExpression(t.identifier('require'), [t.stringLiteral('node:process')]))]),
254
260
  t.ifStatement(t.binaryExpression('in', t.stringLiteral(runtimeBlockedChannel), t.identifier('processHost')), t.returnStatement(fallbackModule('processHost'))),
255
261
  t.variableDeclaration('const', [t.variableDeclarator(t.identifier('loaded'), t.memberExpression(t.identifier('processHost'), t.stringLiteral(runtimeGuardChannel), true))]),
256
262
  t.returnStatement(t.conditionalExpression(t.logicalExpression('&&', t.identifier('loaded'), t.binaryExpression('===', t.unaryExpression('typeof', t.memberExpression(t.identifier('loaded'), t.identifier('safeSelectProbe'))), t.stringLiteral('function'))), t.identifier('loaded'), fallbackModule('processHost'))),
@@ -266,9 +272,10 @@ function passthroughHelper(t, helperName) {
266
272
  function sitePassthroughHelper(t, helperName) {
267
273
  return t.functionDeclaration(t.identifier(helperName), [t.identifier('value')], t.blockStatement([t.returnStatement(t.identifier('value'))]));
268
274
  }
269
- function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, controlChannel, runtimeIntegrity, includedQualifiedNames) {
275
+ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, controlChannel, runtimeIntegrity, includedQualifiedNames, parserPlugins, recordSourcePositions, skipDirective, moduleScopeProbes, }) {
270
276
  if (hasGeneratedInstrumentation(code))
271
277
  return null;
278
+ let skippedByDirective = false;
272
279
  const probes = { branches: 0, parameters: 0, bindings: 0, returns: 0, throws: 0 };
273
280
  const ordinals = new WeakMap();
274
281
  const functionIdentities = new WeakMap();
@@ -297,13 +304,24 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
297
304
  const fn = path.isFunction?.() ? path : path.getFunctionParent();
298
305
  return fn ? functionIdentities.get(fn.node) ?? `${functionName(fn)}:unmapped` : '(top):0';
299
306
  };
300
- const site = (fn, qualifiedName, owner, kind, structuralKey, label, allowedFields = []) => {
307
+ const site = (fn, qualifiedName, owner, kind, structuralKey,
308
+ // Candidate nodes for the site's source position, most specific first.
309
+ // Nodes this pass synthesizes (the return ensureBlock wraps around an
310
+ // arrow's expression body) have no location; the next candidate does.
311
+ positionNodes, label, allowedFields = []) => {
301
312
  const targetId = `site-${shortHash([sourcePath, owner, kind, structuralKey])}`;
302
313
  const value = `${sourcePath}#${fn}:${targetId}${label ? `(${label})` : ''}`;
303
314
  // Leave room under the ingestion endpoint's 512-character key cap for a
304
315
  // static field label and its longest value-shape outcome.
305
316
  if (value.length > 360)
306
317
  throw new Error(`Telemetry site exceeds the ingestion key limit in ${sourcePath}`);
318
+ let position = null;
319
+ if (recordSourcePositions) {
320
+ const start = positionNodes.find(node => node?.loc?.start)?.loc.start;
321
+ if (!start)
322
+ throw new Error(`Telemetry site ${value} has no source position`);
323
+ position = { line: start.line, column: start.column };
324
+ }
307
325
  sites.set(value, {
308
326
  probe_key: value,
309
327
  site_id: targetId,
@@ -314,6 +332,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
314
332
  structural_key: structuralKey,
315
333
  ...(label ? { label } : {}),
316
334
  allowed_fields: [...allowedFields],
335
+ ...(position ?? {}),
317
336
  });
318
337
  return value;
319
338
  };
@@ -336,8 +355,9 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
336
355
  const functionPath = path.isFunction?.() ? path : path.getFunctionParent();
337
356
  return Boolean(functionPath && includedQualifiedNames.has(qualifiedFunctionName(functionPath)));
338
357
  };
358
+ const outsideProbedScope = (path) => !moduleScopeProbes && !path.getFunctionParent();
339
359
  const wrapTest = (path, syntax) => {
340
- if (skipFileProbes || insideAccessor(path) || !selectedFunction(path))
360
+ if (skipFileProbes || insideAccessor(path) || !selectedFunction(path) || outsideProbedScope(path))
341
361
  return;
342
362
  const testPath = path.get('test');
343
363
  const test = testPath?.node;
@@ -347,7 +367,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
347
367
  const functionPath = path.getFunctionParent();
348
368
  const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
349
369
  const ordinal = nextOrdinal(path, `branch:${syntax}`);
350
- const branchSite = site(fn, qualifiedName, ownerIdentity(path), 'condition', `${syntax}:${ordinal}`);
370
+ const branchSite = site(fn, qualifiedName, ownerIdentity(path), 'condition', `${syntax}:${ordinal}`, [test, path.node]);
351
371
  testPath.replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstBranch', branchSite), test));
352
372
  testPath.skip();
353
373
  probes.branches++;
@@ -356,6 +376,11 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
356
376
  visitor: {
357
377
  Program: {
358
378
  enter(path) {
379
+ if (skipDirective && (path.node.directives ?? []).some((directive) => directive.value?.value === skipDirective)) {
380
+ skippedByDirective = true;
381
+ path.stop();
382
+ return;
383
+ }
359
384
  helperName = path.scope.generateUidIdentifier('__haystackTelemetryPassthrough').name;
360
385
  siteHelperName = path.scope.generateUidIdentifier('__haystackTelemetrySitePassthrough').name;
361
386
  runtimeControlName = path.scope.generateUidIdentifier('__haystackTelemetryControl').name;
@@ -424,7 +449,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
424
449
  for (const [parameterIndex, parameter] of path.node.params.entries()) {
425
450
  for (const [bindingIndex, identifier] of parameterBindings(t, parameter).entries()) {
426
451
  const fields = sourceFieldsForParameter(path, identifier.name);
427
- const parameterSite = site(fn, qualifiedFunctionName(path), ownerIdentity(path), 'parameter', `${parameterIndex}:${bindingIndex}:${identifier.name}`, `parameter:${identifier.name}`, fields);
452
+ const parameterSite = site(fn, qualifiedFunctionName(path), ownerIdentity(path), 'parameter', `${parameterIndex}:${bindingIndex}:${identifier.name}`, [identifier, parameter, path.node], `parameter:${identifier.name}`, fields);
428
453
  statements.push(t.expressionStatement(optionalProbeCall(t, siteProbeName(path, '__hstValue', parameterSite), t.identifier(identifier.name))));
429
454
  probes.parameters++;
430
455
  }
@@ -448,7 +473,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
448
473
  },
449
474
  VariableDeclarator: {
450
475
  exit(path) {
451
- if (skipFileProbes || insideAccessor(path) || !selectedFunction(path))
476
+ if (skipFileProbes || insideAccessor(path) || !selectedFunction(path) || outsideProbedScope(path))
452
477
  return;
453
478
  if (!t.isIdentifier(path.node.id) || !path.node.init || !t.isExpression(path.node.init))
454
479
  return;
@@ -456,7 +481,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
456
481
  const functionPath = path.getFunctionParent();
457
482
  const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
458
483
  const ordinal = nextOrdinal(path, `binding:${path.node.id.name}`);
459
- const valueSite = site(fn, qualifiedName, ownerIdentity(path), 'binding', `${path.node.id.name}:${ordinal}`, `binding:${path.node.id.name}`);
484
+ const valueSite = site(fn, qualifiedName, ownerIdentity(path), 'binding', `${path.node.id.name}:${ordinal}`, [path.node], `binding:${path.node.id.name}`);
460
485
  path.get('init').replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstValue', valueSite), path.node.init));
461
486
  path.get('init').skip();
462
487
  probes.bindings++;
@@ -473,7 +498,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
473
498
  const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
474
499
  const ordinal = nextOrdinal(path, 'return');
475
500
  const fields = sourceFieldsForReturnedObject(path.node.argument);
476
- const returnSite = site(fn, qualifiedName, ownerIdentity(path), 'return', String(ordinal), 'return', fields);
501
+ const returnSite = site(fn, qualifiedName, ownerIdentity(path), 'return', String(ordinal), [path.node, path.node.argument], 'return', fields);
477
502
  path.get('argument').replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstValue', returnSite), path.node.argument));
478
503
  path.get('argument').skip();
479
504
  probes.returns++;
@@ -481,7 +506,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
481
506
  },
482
507
  ThrowStatement: {
483
508
  exit(path) {
484
- if (skipFileProbes || insideAccessor(path) || !selectedFunction(path))
509
+ if (skipFileProbes || insideAccessor(path) || !selectedFunction(path) || outsideProbedScope(path))
485
510
  return;
486
511
  if (!path.node.argument || !t.isExpression(path.node.argument))
487
512
  return;
@@ -489,7 +514,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
489
514
  const functionPath = path.getFunctionParent();
490
515
  const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
491
516
  const ordinal = nextOrdinal(path, 'throw');
492
- const throwSite = site(fn, qualifiedName, ownerIdentity(path), 'throw', String(ordinal), 'throw');
517
+ const throwSite = site(fn, qualifiedName, ownerIdentity(path), 'throw', String(ordinal), [path.node, path.node.argument], 'throw');
493
518
  path.get('argument').replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstThrow', throwSite), path.node.argument));
494
519
  path.get('argument').skip();
495
520
  probes.throws++;
@@ -502,7 +527,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
502
527
  const transformed = babel.transformSync(code, {
503
528
  parserOpts: {
504
529
  sourceType: 'unambiguous',
505
- plugins: ['jsx'],
530
+ plugins: parserPlugins,
506
531
  },
507
532
  plugins: [plugin],
508
533
  babelrc: false,
@@ -512,6 +537,8 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
512
537
  retainLines: true,
513
538
  compact: false,
514
539
  });
540
+ if (skippedByDirective)
541
+ return null;
515
542
  if (!transformed?.code)
516
543
  throw new Error(`Instrumentation produced no code for ${sourcePath}`);
517
544
  if (!helperName || !siteHelperName || !runtimeControlName) {
@@ -662,6 +689,24 @@ function sourceForOutput(output, dist, sourceRoot) {
662
689
  function runtimeAssetPath() {
663
690
  return join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'telemetry', RUNTIME_FILENAME);
664
691
  }
692
+ /**
693
+ * The runtime source bound to one control channel, plus the integrity token
694
+ * generated probes present to it and the hash a bootstrap verifies before
695
+ * evaluating it. Deterministic in the channel, so a bundler loader running in
696
+ * several worker processes and the step that later writes the runtime agree
697
+ * without exchanging anything.
698
+ */
699
+ export function authenticateTelemetryRuntime(controlChannel) {
700
+ const runtimeTemplate = readFileSync(runtimeAssetPath(), 'utf8');
701
+ if (!runtimeTemplate.includes(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER) ||
702
+ !runtimeTemplate.includes(RUNTIME_INTEGRITY_PLACEHOLDER)) {
703
+ throw new Error('Telemetry runtime is missing its authentication placeholders');
704
+ }
705
+ const runtimeChannelSource = runtimeTemplate.replaceAll(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER, controlChannel);
706
+ const integrity = createHash('sha256').update(runtimeChannelSource).digest('hex');
707
+ const source = runtimeChannelSource.replaceAll(RUNTIME_INTEGRITY_PLACEHOLDER, integrity);
708
+ return { source, integrity, hash: createHash('sha256').update(source).digest('hex') };
709
+ }
665
710
  function readExistingManifest(path) {
666
711
  if (!existsSync(path))
667
712
  return null;
@@ -741,15 +786,7 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
741
786
  normalizedPath(relative(dist, output)),
742
787
  normalizeLineEndings(readFileSync(output, 'utf8')),
743
788
  ]))}`;
744
- const runtimeTemplate = readFileSync(runtimeAssetPath(), 'utf8');
745
- if (!runtimeTemplate.includes(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER) ||
746
- !runtimeTemplate.includes(RUNTIME_INTEGRITY_PLACEHOLDER)) {
747
- throw new Error('Telemetry runtime is missing its authentication placeholders');
748
- }
749
- const runtimeChannelSource = runtimeTemplate.replaceAll(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER, controlChannel);
750
- const runtimeIntegrity = createHash('sha256').update(runtimeChannelSource).digest('hex');
751
- const authenticatedRuntime = runtimeChannelSource.replaceAll(RUNTIME_INTEGRITY_PLACEHOLDER, runtimeIntegrity);
752
- const authenticatedRuntimeHash = createHash('sha256').update(authenticatedRuntime).digest('hex');
789
+ const { source: authenticatedRuntime, integrity: runtimeIntegrity, hash: authenticatedRuntimeHash, } = authenticateTelemetryRuntime(controlChannel);
753
790
  const pending = [];
754
791
  const skipped = [];
755
792
  for (const output of outputFiles) {
@@ -765,7 +802,19 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
765
802
  if (!SAFE_SOURCE_PATH.test(sourcePath)) {
766
803
  throw new Error(`Source path contains a telemetry-reserved character (# or |): ${sourcePath}`);
767
804
  }
768
- const transformed = instrumentSource(readFileSync(output, 'utf8'), sourcePath, runtimeSpecifierForOutput(output, dist), moduleTypeForOutput(output, dist), controlChannel, runtimeIntegrity, selectiveInstrumentation ? selectedSymbolsBySource.get(sourcePath) ?? new Set() : null);
805
+ const transformed = instrumentSource(readFileSync(output, 'utf8'), {
806
+ sourcePath,
807
+ runtimeSpecifier: runtimeSpecifierForOutput(output, dist),
808
+ moduleType: moduleTypeForOutput(output, dist),
809
+ controlChannel,
810
+ runtimeIntegrity,
811
+ includedQualifiedNames: selectiveInstrumentation ? selectedSymbolsBySource.get(sourcePath) ?? new Set() : null,
812
+ parserPlugins: ['jsx'],
813
+ // Compiled output has no source map here: its coordinates are not the
814
+ // source's, so sites carry none (see InstrumentedSite.line).
815
+ recordSourcePositions: false,
816
+ moduleScopeProbes: true,
817
+ });
769
818
  if (transformed)
770
819
  pending.push({ output, source: sourcePath, ...transformed });
771
820
  }
@@ -818,6 +867,128 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
818
867
  writeFileSync(manifestPath, `${JSON.stringify(result, null, 2)}\n`);
819
868
  return result;
820
869
  }
870
+ // ─── Bundled (source-level) instrumentation ─────────────────────────────────
871
+ // A bundler packs server code into chunks that mirror no source file, so the
872
+ // compiled-output pass above cannot map them. These two entry points let a
873
+ // bundler loader instrument each source module as it is compiled, and a
874
+ // post-compile step write the runtime beside the build. The runtime is never
875
+ // bundled: a preload evaluates it by its real path.
876
+ // `<T>value` casts are JSX in a .tsx file, so typescript and jsx go together
877
+ // only there. Every set parses legacy decorators (TypeScript's
878
+ // experimentalDecorators: `@Entity()` classes, members and parameters), which
879
+ // the generator prints back where they were; `@` is a syntax error anywhere
880
+ // else, so the plugin only widens what parses. Import attributes (`with
881
+ // { type: 'json' }`) and `using` / `await using` are on by default in Babel 8
882
+ // and named so the accepted syntax is stated here; Babel 8 removed the older
883
+ // `assert { }` form, so such a module is one the loader leaves unchanged.
884
+ const SHARED_PARSER_PLUGINS = ['decorators-legacy', 'importAttributes', 'explicitResourceManagement'];
885
+ const BUNDLED_PARSER_PLUGINS = {
886
+ '.ts': ['typescript', ...SHARED_PARSER_PLUGINS],
887
+ '.mts': ['typescript', ...SHARED_PARSER_PLUGINS],
888
+ '.cts': ['typescript', ...SHARED_PARSER_PLUGINS],
889
+ '.tsx': ['typescript', 'jsx', ...SHARED_PARSER_PLUGINS],
890
+ '.js': ['jsx', ...SHARED_PARSER_PLUGINS],
891
+ '.jsx': ['jsx', ...SHARED_PARSER_PLUGINS],
892
+ '.mjs': ['jsx', ...SHARED_PARSER_PLUGINS],
893
+ '.cjs': ['jsx', ...SHARED_PARSER_PLUGINS],
894
+ };
895
+ export const BUNDLED_SOURCE_EXTENSIONS = Object.keys(BUNDLED_PARSER_PLUGINS);
896
+ const BUNDLED_REGISTER_FILENAME = 'register.cjs';
897
+ /**
898
+ * Instrument one source module inside a bundler. `sourcePath` is the
899
+ * repository-relative path every site records, so production counts join the
900
+ * change's files and line ranges exactly. A module Babel cannot parse is
901
+ * reported, not thrown: the bundler's compiler may accept syntax Babel does
902
+ * not, and telemetry must never be the reason a build fails. Any other error
903
+ * is ours and stays loud.
904
+ */
905
+ export function instrumentBundledModule(code, options) {
906
+ const parserPlugins = BUNDLED_PARSER_PLUGINS[extname(options.sourcePath)];
907
+ if (!parserPlugins)
908
+ throw new Error(`Telemetry cannot parse ${options.sourcePath}: unsupported extension`);
909
+ if (!SAFE_SOURCE_PATH.test(options.sourcePath) || options.sourcePath.startsWith('/') ||
910
+ options.sourcePath.split('/').some(part => !part || part === '.' || part === '..')) {
911
+ throw new Error(`Telemetry source path must be repository-relative without # or |: ${options.sourcePath}`);
912
+ }
913
+ let instrumented;
914
+ try {
915
+ instrumented = instrumentSource(code, {
916
+ sourcePath: options.sourcePath,
917
+ runtimeSpecifier: '',
918
+ moduleType: 'bundled',
919
+ controlChannel: options.controlChannel,
920
+ runtimeIntegrity: options.runtimeIntegrity,
921
+ includedQualifiedNames: null,
922
+ parserPlugins,
923
+ recordSourcePositions: true,
924
+ skipDirective: 'use client',
925
+ moduleScopeProbes: false,
926
+ });
927
+ }
928
+ catch (error) {
929
+ if (error?.code !== 'BABEL_PARSE_ERROR')
930
+ throw error;
931
+ // The parser's own one-line reason and position, e.g. "Missing semicolon.
932
+ // (1:30)"; the code frame below it would copy customer source into the log.
933
+ const reason = String(error.message).split('\n')[0].replace(/^unknown:\s*/, '');
934
+ return { kind: 'unparsed', reason };
935
+ }
936
+ return instrumented ? { kind: 'instrumented', ...instrumented } : { kind: 'unchanged' };
937
+ }
938
+ /**
939
+ * Write the runtime, its manifest and the preload that installs it into
940
+ * `<outputDirectory>/.haystack-telemetry/`. The runtime reads the manifest
941
+ * beside its own real file; nothing here depends on where a bundler put the
942
+ * chunks. Returns the preload's absolute path for NODE_OPTIONS=--require.
943
+ */
944
+ export function writeBundledTelemetryRuntime(options) {
945
+ const modules = [...options.modules].sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
946
+ const sitesByProbe = new Map();
947
+ for (const module of modules) {
948
+ for (const site of module.sites) {
949
+ const existing = sitesByProbe.get(site.probe_key);
950
+ if (existing && JSON.stringify(existing) !== JSON.stringify(site)) {
951
+ throw new Error(`Telemetry site ${site.probe_key} was compiled from two different versions of ${site.source_path}`);
952
+ }
953
+ sitesByProbe.set(site.probe_key, site);
954
+ }
955
+ }
956
+ const sites = [...sitesByProbe.values()].sort((left, right) => left.site_id.localeCompare(right.site_id));
957
+ if (sites.length === 0)
958
+ throw new Error('Telemetry instrumented no server source; nothing to install');
959
+ if (sites.length > MAX_INSTRUMENTED_SITES) {
960
+ throw new Error(`Build contains ${sites.length} telemetry sites; maximum is ${MAX_INSTRUMENTED_SITES}`);
961
+ }
962
+ // The build identity covers exactly what was instrumented (every site's
963
+ // coordinates), so two builds of identical server source share one identity
964
+ // and any source change produces a new one.
965
+ const buildId = shortHash([options.bundler, ...sites.map(site => JSON.stringify(site))]);
966
+ const runtime = authenticateTelemetryRuntime(options.controlChannel);
967
+ const outputDirectory = resolve(options.outputDirectory);
968
+ const runtimeDirectory = join(outputDirectory, RUNTIME_DIRECTORY);
969
+ mkdirSync(runtimeDirectory, { recursive: true });
970
+ const manifest = {
971
+ schema_version: MANIFEST_SCHEMA_VERSION,
972
+ build_id: buildId,
973
+ bundler: options.bundler,
974
+ source_root: options.sourceRoot,
975
+ entry: null,
976
+ runtime: `${RUNTIME_DIRECTORY}/${RUNTIME_FILENAME}`,
977
+ instrumented_files: modules.map(module => ({ source_path: module.sourcePath, probes: module.probes })),
978
+ skipped_unparsed_files: [...new Map(options.unparsed.map(file => [file.sourcePath, file])).values()]
979
+ .sort((left, right) => left.sourcePath.localeCompare(right.sourcePath))
980
+ .map(file => ({ source_path: file.sourcePath, reason: file.reason })),
981
+ sites,
982
+ };
983
+ writeFileSync(join(runtimeDirectory, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`);
984
+ writeFileSync(join(runtimeDirectory, RUNTIME_FILENAME), runtime.source);
985
+ const registerPath = join(runtimeDirectory, BUNDLED_REGISTER_FILENAME);
986
+ // The same hash-verified, cache-isolated evaluation the compiled-output
987
+ // entry bootstrap performs, run as a preload before the server's first
988
+ // module so every bundled probe binds on its first evaluation.
989
+ writeFileSync(registerPath, bootstrapFor(registerPath, outputDirectory, buildId, runtime.hash, `${options.controlChannel}_runtimeGuard`, `${options.controlChannel}_runtimeBlocked`));
990
+ return { registerPath, manifest };
991
+ }
821
992
  export async function telemetryInstrumentCommand(distDirectory, options) {
822
993
  let includeSymbols = options.includeSymbols;
823
994
  if (options.includeSymbolsFile) {
@@ -14,11 +14,13 @@ import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSi
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
+ import { sameNotes } from './onboarding-notes.js';
17
18
  import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
18
19
  import { findGitRoot } from '../utils/hooks.js';
19
20
  const ONBOARDING_PATH = '/api/agent/cloud-verifier/onboarding';
20
21
  const FACTS_PATH = `${ONBOARDING_PATH}/facts`;
21
22
  const ANSWERS_PATH = `${ONBOARDING_PATH}/answers`;
23
+ const NOTES_PATH = `${ONBOARDING_PATH}/notes`;
22
24
  const ONBOARD_RUN_ID = /^ob_[0-9a-f]{48}$/;
23
25
  const SHA256 = /^[0-9a-f]{64}$/;
24
26
  const COMMIT = /^[0-9a-f]{40}$/;
@@ -95,6 +97,8 @@ export function parseOnboardingStatus(value, expected) {
95
97
  invalid('its version choices');
96
98
  if (!isStrings(value.standIns) || !isStrings(value.unsetProviders) || !isStrings(value.standInGaps))
97
99
  invalid('its stand-ins');
100
+ if (!(value.worldMemoryMib === null || (typeof value.worldMemoryMib === 'number' && Number.isInteger(value.worldMemoryMib))))
101
+ invalid('its world memory');
98
102
  if (value.block !== null)
99
103
  parseOnboardingBlock(value.block);
100
104
  const checkpoint = value.checkpoint;
@@ -229,9 +233,15 @@ export function onboardingStep(status) {
229
233
  const stages = status.checkpoint?.execution.stages ?? [];
230
234
  const done = stages.filter(stage => stage.status === 'ok' || stage.status === 'reused').map(stage => stage.stage);
231
235
  const current = status.checkpoint?.stage;
236
+ // A deferred run's checkpoint is its last attempt's (stage cleanup): it is waiting, not cleaning up (2026-10-05, Excalidraw
237
+ // showed "cleaning up" for ten minutes behind a duplicate onboarding of the same commit).
232
238
  const doing = status.state !== 'onboarding' ? status.state
233
- : current === undefined ? 'queued' : STAGE_WORDS[current];
234
- return `Onboarding: ${doing}${done.length ? ` (done: ${done.join(', ')})` : ''}.`;
239
+ : status.checkpoint?.error?.code === 'deferred' ? 'waiting for an earlier onboarding of this commit to finish, then reusing it'
240
+ : current === undefined ? 'queued' : STAGE_WORDS[current];
241
+ // A world larger than the first size is what onboarding found the app needs; every crawl's VMs are that size.
242
+ const memory = status.state === 'ready' && status.worldMemoryMib !== null && status.worldMemoryMib > 4096
243
+ ? ` Its copies run with ${status.worldMemoryMib} MiB of memory: it ran out at 4096.` : '';
244
+ return `Onboarding: ${doing}${done.length ? ` (done: ${done.join(', ')})` : ''}.${memory}`;
235
245
  }
236
246
  /** The stage list, one line per stage the run has reached. */
237
247
  export function onboardingStageLines(status) {
@@ -369,6 +379,28 @@ export async function verifyAnswerCommand(questionId, choice, options) {
369
379
  return;
370
380
  }
371
381
  }
382
+ /** Makes the coding agent's notes the repository's onboarding notes (rule 7, amendment 18), unless they are exactly these
383
+ * already: 'unchanged' then, so running init again with the same notes starts no new onboarding. */
384
+ export async function writeOnboardingNotes(repository, notes, token) {
385
+ for (let attempt = 1;; attempt += 1) {
386
+ const facts = await readFacts(repository, token);
387
+ if (sameNotes(facts.facts.notes, notes))
388
+ return 'unchanged';
389
+ const request = { repository, expectedFactsDigest: facts.factsDigest, notes };
390
+ const response = await gatewayFetch(`${NOTES_PATH}?repository=${encodeURIComponent(repository)}`, token, {
391
+ method: 'POST', body: JSON.stringify(request),
392
+ });
393
+ if (response.status === 409 && attempt < 3) {
394
+ await response.body?.cancel();
395
+ continue;
396
+ }
397
+ if (!response.ok)
398
+ throw await classifyHttpError(response, `Haystack API ${NOTES_PATH}`);
399
+ if (!sameNotes(parseFactsView(await body(response, 'facts'), repository).facts.notes, notes))
400
+ invalid('the notes it answered with');
401
+ return 'written';
402
+ }
403
+ }
372
404
  const clock = () => new Date().toTimeString().slice(0, 8);
373
405
  /** `haystack verify onboarding`: the onboarding of the base `haystack verify` would use for this
374
406
  * checkout, read without starting anything; --wait follows it while it runs. */
@@ -264,7 +264,11 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
264
264
  /** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
265
265
  budgetMs,
266
266
  /** Amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' is `haystack verify`. */
267
- mode = 'crawl') {
267
+ mode = 'crawl',
268
+ /** Amendment 16: `haystack verify --pool`; undefined: the service decides ('auto'). */
269
+ pool,
270
+ /** Amendment 17: the coding agent's `--intent` and `--idea`s; each absent when it gave none. */
271
+ agent = {}) {
268
272
  // Capture this before git and compression work so delivery latency cannot
269
273
  // make an older stop look newer than a later one.
270
274
  const observedAt = Date.now();
@@ -292,7 +296,8 @@ mode = 'crawl') {
292
296
  cacheKey,
293
297
  patchGzBase64: patchGzip.toString('base64'),
294
298
  patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
295
- ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode } }),
299
+ ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode, ...(pool === undefined ? {} : { pool }),
300
+ ...(agent.intent === undefined ? {} : { intent: agent.intent }), ...(agent.ideas === undefined ? {} : { ideas: agent.ideas }) } }),
296
301
  },
297
302
  };
298
303
  }
@@ -519,7 +524,11 @@ export async function captureCheckout(deadline,
519
524
  /** Amendment 12: the turn-end hook and `verify precompute` ask to prepare (build and freeze); `haystack verify` to crawl. */
520
525
  mode, repository,
521
526
  /** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
522
- minutes) {
527
+ minutes,
528
+ /** `haystack verify --pool`; undefined: the service decides. */
529
+ pool,
530
+ /** `haystack verify --intent` and `--idea` (CRAWL-V1 amendment 17). */
531
+ agent = {}) {
523
532
  const gitRoot = findGitRoot();
524
533
  if (!gitRoot) {
525
534
  throw new PrecomputeFailure('Run this command from a git repository.');
@@ -527,7 +536,7 @@ minutes) {
527
536
  const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
528
537
  const target = repository ?? resolveOriginRepository(gitRoot);
529
538
  const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
530
- const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode);
539
+ const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode, pool, agent);
531
540
  return { metadataDirectory, derivation };
532
541
  }
533
542
  /** The explicit (non-hook) submission of a capture: one synchronous