@haystackeditor/cli 0.23.3 → 0.24.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.
@@ -22,13 +22,14 @@
22
22
  * A blocked onboarding prints why and exactly what would unblock it.
23
23
  */
24
24
  import { execFileSync } from 'node:child_process';
25
+ import { readFileSync } from 'node:fs';
25
26
  import chalk from 'chalk';
26
27
  import { withSchema } from '../schema.js';
27
28
  import { findGitRoot } from '../utils/hooks.js';
28
29
  import { resolveAuthContext } from '../utils/auth.js';
29
30
  import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
30
31
  import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
31
- import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
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';
32
33
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
33
34
  import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, standInGapNotes, waitForOnboarding } from './verify-onboarding.js';
34
35
  const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
@@ -219,9 +220,51 @@ function checkResults(value, view, what) {
219
220
  invalid('its outside-service coverage');
220
221
  if (Object.hasOwn(reach, 'planning'))
221
222
  checkPlanningCoverage(reach.planning);
223
+ // Amendment 17: each idea of the request, in its order, naming only findings these results carry.
224
+ const ideas = value.ideas;
225
+ const findingIds = new Set(value.findings.map(finding => finding.id));
226
+ // Texts as the worker bounds them: nonempty, at most CRAWL_MAX_TEXT_CHARS code points (Astra).
227
+ const bounded = (value) => typeof value === 'string' && value.length > 0 && [...value].length <= CRAWL_MAX_TEXT_CHARS;
228
+ if (ideas !== undefined && (!Array.isArray(ideas) || ideas.length === 0 || ideas.length > CRAWL_MAX_IDEAS || !ideas.every((idea, index) => isRecord(idea)
229
+ && idea.id === `idea-${index + 1}` && bounded(idea.text) && bounded(idea.reason)
230
+ && (idea.state === 'covered' || idea.state === 'blocked' || idea.state === 'unfinished')
231
+ && isStrings(idea.findingIds, CRAWL_MAX_FINDINGS) && idea.findingIds.every(id => findingIds.has(id)))))
232
+ invalid('its ideas');
233
+ if (value.brief !== undefined)
234
+ checkBrief(value.brief);
222
235
  if (value.error !== null)
223
236
  checkError(value.error, 'its error');
224
237
  }
238
+ /** Amendment 18: a brief's shape and bounds, as the worker checks them. */
239
+ function checkBrief(value) {
240
+ const text = (item, empty = false) => typeof item === 'string' && (empty || item.length > 0) && [...item].length <= CRAWL_MAX_TEXT_CHARS;
241
+ const texts = (item, maximum) => Array.isArray(item) && item.length <= maximum && item.every(entry => text(entry));
242
+ const records = (item, maximum, each) => Array.isArray(item) && item.length <= maximum && item.every(row => isRecord(row) && each(row));
243
+ if (!isRecord(value) || !records(value.functions, CRAWL_BRIEF_MAX_FUNCTIONS, fn => text(fn.file) && text(fn.name, true)
244
+ && Array.isArray(fn.lines) && fn.lines.length === 2 && fn.lines.every(isCount) && isCount(fn.distance)
245
+ && (fn.direction === 'changed' || fn.direction === 'upstream' || fn.direction === 'downstream'))
246
+ || !records(value.leftOut, CRAWL_BRIEF_MAX_GROUPS, row => text(row.reason) && isCount(row.count)))
247
+ invalid('its brief');
248
+ const planning = value.planning;
249
+ if (planning !== null && (!isRecord(planning)
250
+ || !records(planning.groups, CRAWL_BRIEF_MAX_GROUPS, group => text(group.id) && texts(group.changeKeys, CRAWL_BRIEF_MAX_FUNCTIONS)
251
+ && records(group.patterns, CRAWL_BRIEF_MAX_GROUPS, pattern => text(pattern.entryId) && text(pattern.mechanism) && text(pattern.symptom, true)))
252
+ || !records(planning.unresolved, CRAWL_BRIEF_MAX_GROUPS, change => text(change.changeKey) && text(change.reason))
253
+ || !records(planning.ideas, CRAWL_MAX_IDEAS, idea => ['id', 'groupId', 'pattern', 'why', 'failure', 'exercise', 'watch', 'start']
254
+ .every(field => text(idea[field])) && texts(idea.setup, CRAWL_MAX_IDEAS))))
255
+ invalid('its brief');
256
+ // Amendment 19: each production row names a function the brief holds.
257
+ const production = value.production;
258
+ const line = (item) => isCount(item) && item >= 1;
259
+ if (production !== undefined && (!isRecord(production) || !isCount(production.windowDays) || !isCount(production.payloads)
260
+ || !text(production.newestAt, true) || typeof production.partial !== 'boolean' || !records(production.functions, CRAWL_BRIEF_MAX_FUNCTIONS, fn => isCount(fn.index)
261
+ && fn.index < value.functions.length && isCount(fn.branchesOmitted) && isCount(fn.valuesOmitted)
262
+ && records(fn.branches, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, branch => line(branch.line) && isCount(branch.whenTrue)
263
+ && isCount(branch.whenFalse) && typeof branch.capped === 'boolean')
264
+ && 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))))))
266
+ invalid('its brief');
267
+ }
225
268
  function checkManifest(value, view) {
226
269
  checkResults(value, view, 'its results');
227
270
  if (value.status !== view.status)
@@ -451,6 +494,15 @@ export function formatCrawl(view) {
451
494
  lines.push(` ${plural(published.notFinishedInTime, 'difference')} ${published.notFinishedInTime === 1 ? 'was' : 'were'}`
452
495
  + ' still being checked when its time ran out.');
453
496
  }
497
+ // Amendment 17: what became of each idea the coding agent gave, with the numbers of the findings above that came from it.
498
+ if (published.ideas !== undefined) {
499
+ const numbered = new Map(findings.map((finding, index) => [finding.id, index + 1]));
500
+ lines.push('', chalk.bold('Your ideas'));
501
+ for (const idea of published.ideas) {
502
+ const found = idea.findingIds.map(id => numbered.get(id)).filter((n) => n !== undefined);
503
+ lines.push(` ${idea.state === 'covered' ? 'tried' : idea.state === 'blocked' ? 'could not try' : 'not finished'}: ${safe(idea.text)}`, ` ${safe(idea.reason)}`, ...(found.length ? [` Findings from it: ${found.map(n => `#${n}`).join(', ')}`] : []));
504
+ }
505
+ }
454
506
  lines.push('', chalk.bold('What the crawl never ran'));
455
507
  if (published.reach.unreachedChangedFiles.length === 0)
456
508
  lines.push(' Every changed file ran at least once.');
@@ -514,6 +566,28 @@ function poolOption(value) {
514
566
  throw new Error(`--pool must be one of ${CRAWL_POOLS.join(', ')}.`);
515
567
  return value;
516
568
  }
569
+ /** --intent, --idea and --ideas-file (CRAWL-V1 amendment 17): each text trimmed, 1 to CRAWL_MAX_TEXT_CHARS characters; at most
570
+ * CRAWL_MAX_IDEAS ideas, each once. Absent ones stay absent, so a crawl asked without them keeps its identity. */
571
+ export function agentDirection(options) {
572
+ const text = (value, what) => {
573
+ const trimmed = value.trim();
574
+ if (trimmed.length === 0 || [...trimmed].length > CRAWL_MAX_TEXT_CHARS)
575
+ throw new Error(`${what} must be 1 to ${CRAWL_MAX_TEXT_CHARS} characters.`);
576
+ return trimmed;
577
+ };
578
+ const fromFile = options.ideasFile === undefined ? [] : (() => {
579
+ const parsed = JSON.parse(readFileSync(options.ideasFile, 'utf8'));
580
+ if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string'))
581
+ throw new Error('--ideas-file must hold a JSON array of strings.');
582
+ return parsed;
583
+ })();
584
+ const ideas = [...options.idea ?? [], ...fromFile].map(idea => text(idea, 'Each idea'));
585
+ if (ideas.length > CRAWL_MAX_IDEAS)
586
+ throw new Error(`A crawl takes at most ${CRAWL_MAX_IDEAS} ideas; ${ideas.length} were given.`);
587
+ if (new Set(ideas).size !== ideas.length)
588
+ throw new Error('The same idea was given twice.');
589
+ return { ...(options.intent === undefined ? {} : { intent: text(options.intent, '--intent') }), ...(ideas.length ? { ideas } : {}) };
590
+ }
517
591
  /** --minutes: whole minutes from 1 to 30 (CRAWL-V1 amendment 8). */
518
592
  function minutesOption(value) {
519
593
  if (value === undefined)
@@ -713,13 +787,198 @@ function finishWithOnboarding(status, json, change) {
713
787
  console.log(formatOnboarding(status));
714
788
  process.exitCode = onboardingExitCode(status);
715
789
  }
790
+ /** The brief a crawl view holds (CRAWL-V1 amendment 18): its sealed manifest's, else its latest answer's. */
791
+ function briefOf(view) {
792
+ return view.manifest?.brief ?? view.answer?.brief ?? null;
793
+ }
794
+ /** Follows a run until it holds a brief or ends (amendment 18): its own read held until it changes, as waitForCrawl's is. */
795
+ async function waitForBrief(first, identity, token) {
796
+ const interrupted = () => {
797
+ console.error('\nStopped waiting. The change keeps being prepared; run `haystack pre-verify` again to see its brief.');
798
+ process.exit(130);
799
+ };
800
+ process.once('SIGINT', interrupted);
801
+ try {
802
+ let view = first;
803
+ progress(`Waiting for the brief of ${view.runId}: the change is built and prepared first (Ctrl-C stops waiting).`);
804
+ progress(currentStep(view));
805
+ let failing = false;
806
+ let answeredAt = Date.now();
807
+ while (briefOf(view) === null && !TERMINAL.has(reportedStatus(view))) {
808
+ let next;
809
+ try {
810
+ next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
811
+ }
812
+ catch (error) {
813
+ const why = transientReadFailure(error);
814
+ if (why === null)
815
+ throw error;
816
+ if (Date.now() - answeredAt >= SERVICE_SILENCE_LIMIT_MS)
817
+ throw new ServiceSilentError('crawl', 'haystack pre-verify');
818
+ if (!failing)
819
+ progress(`The run's status is unavailable for now (${why}); reading it again.`);
820
+ failing = true;
821
+ await new Promise(resolve => setTimeout(resolve, TRANSIENT_READ_PAUSE_MS));
822
+ continue;
823
+ }
824
+ failing = false;
825
+ answeredAt = Date.now();
826
+ if (!TERMINAL.has(reportedStatus(next)) && currentStep(next) !== currentStep(view))
827
+ progress(currentStep(next));
828
+ view = next;
829
+ }
830
+ return view;
831
+ }
832
+ finally {
833
+ process.removeListener('SIGINT', interrupted);
834
+ }
835
+ }
836
+ /** 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
+ * ideas. Every function is in --json; the text shows the changed ones and those one step away, and says how many more. */
838
+ export function formatBrief(brief) {
839
+ const lines = [chalk.bold('What your change touches')];
840
+ const near = brief.functions.filter(fn => fn.distance <= 1);
841
+ // Amendment 19: what production saw of each function shown, from the repository's own telemetry.
842
+ const seen = new Map((brief.production?.functions ?? []).map(row => [row.index, row]));
843
+ if (brief.functions.length === 0)
844
+ lines.push(' The blast radius found no affected functions.');
845
+ brief.functions.forEach((fn, index) => {
846
+ if (fn.distance > 1)
847
+ return;
848
+ lines.push(` ${fn.direction === 'changed' ? 'changed' : `${fn.direction}, ${plural(fn.distance, 'step')} away`}: `
849
+ + `${safe(fn.file)}:${fn.lines[0]}-${fn.lines[1]} ${safe(fn.name)}`);
850
+ const row = seen.get(index);
851
+ if (row === undefined)
852
+ return;
853
+ for (const branch of row.branches)
854
+ lines.push(` in production: line ${branch.line} was true ${branch.whenTrue}${branch.capped ? '+' : ''} times,`
855
+ + ` false ${branch.whenFalse}${branch.capped ? '+' : ''} times`);
856
+ if (row.branchesOmitted)
857
+ lines.push(` and ${plural(row.branchesOmitted, 'more branch site')} (in --json)`);
858
+ for (const observed of row.values)
859
+ lines.push(` in production: line ${observed.line} ${safe(observed.what)} was `
860
+ + observed.shapes.map(shape => `${safe(shape.shape)} (${shape.count})`).join(', '));
861
+ if (row.valuesOmitted)
862
+ lines.push(` and ${plural(row.valuesOmitted, 'more observed value')} (in --json)`);
863
+ });
864
+ if (brief.production) {
865
+ const shownSeen = brief.production.functions.filter(row => brief.functions[row.index].distance <= 1).length;
866
+ lines.push(` Production telemetry: the last ${brief.production.windowDays} days, newest ${safe(brief.production.newestAt)};`
867
+ + ` ${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' : ''}.`);
869
+ }
870
+ else
871
+ lines.push(' No production telemetry for this repository: set it up to see how this code runs for real users.');
872
+ if (brief.functions.length > near.length) {
873
+ lines.push(` ${plural(brief.functions.length - near.length, 'more function')} further away (all of them are in --json).`);
874
+ }
875
+ for (const item of brief.leftOut)
876
+ lines.push(` Left out: ${item.count} (${safe(item.reason)})`);
877
+ lines.push('', chalk.bold('What could break, from failure patterns that fit this change'));
878
+ if (brief.planning === null)
879
+ lines.push(' No planning ran for this change: no ideas of ours to offer.');
880
+ else {
881
+ if (brief.planning.ideas.length === 0)
882
+ lines.push(' No situation was planned for this change.');
883
+ brief.planning.ideas.forEach((idea, index) => lines.push(` ${index + 1}. Try: ${safe(idea.exercise)}`, ...(idea.setup.length ? [` Set up first: ${idea.setup.map(safe).join('; ')}`] : []), ` Watch: ${safe(idea.watch)}`, ` Why: ${safe(idea.why)}`, ` It would show as: ${safe(idea.failure)}`, ` Starts from: ${safe(idea.start)}`));
884
+ if (brief.planning.unresolved.length) {
885
+ lines.push(' Changes no behavior group holds (no ideas of ours for these):');
886
+ for (const change of brief.planning.unresolved)
887
+ lines.push(` ${safe(change.changeKey)}: ${safe(change.reason)}`);
888
+ }
889
+ }
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.');
891
+ return lines.join('\n');
892
+ }
893
+ /** `haystack pre-verify` (CRAWL-V1 amendment 18): the brief of the current change, before any crawl. It captures the checkout as
894
+ * 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
895
+ * prints the run's brief. Exit codes: 0 with a brief, 2 when the run ended without one, 3 onboarding blocked, 1 otherwise. */
896
+ export async function preVerifyCommand(options) {
897
+ const note = (line) => { if (options.json)
898
+ console.error(line);
899
+ else
900
+ console.log(line); };
901
+ const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'prepare', repositoryOverride(options.repo));
902
+ const request = capture.derivation.request;
903
+ const change = checkedChange(request.baseCommit, request.workCommit);
904
+ const finish = (onboarding, view) => {
905
+ if (options.json)
906
+ 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`);
908
+ };
909
+ if (!request.crawl) {
910
+ note(`Nothing to check: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
911
+ finish(null, null);
912
+ return;
913
+ }
914
+ note(checkedLine(change));
915
+ const identity = { repository: `${request.owner}/${request.repository}`, baseCommit: request.baseCommit,
916
+ workCommit: request.workCommit, treeSha: request.crawl.treeSha, changeTitle: request.crawl.changeTitle, budgetMs: request.crawl.budgetMs ?? null };
917
+ const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
918
+ let view = await readWithRetries(() => readCrawl(identity, auth.token));
919
+ let onboarding = null;
920
+ const stale = view === null ? null : staleCrawl(view, identity);
921
+ // A run of this capture that is preparing, prepared or crawling holds (or will hold) the brief; any other is asked for again,
922
+ // in mode 'prepare' (amendment 12: that never narrows a crawl).
923
+ if (view === null || (stale !== null && stale !== 'prepare-only')) {
924
+ const found = view === null || stale === null ? null : { view, stale };
925
+ let submitted = await submitCapture(capture, auth.token, found);
926
+ note(submitted.line);
927
+ if (submitted.onboarding !== null) {
928
+ onboarding = await readWithRetries(() => readOnboardingStatus(identity.repository, identity.baseCommit, auth.token));
929
+ reportOnboardingState(onboarding);
930
+ if (options.wait !== false && onboarding.state === 'onboarding') {
931
+ onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack pre-verify');
932
+ reportOnboardingState(onboarding);
933
+ }
934
+ if (onboarding.state !== 'ready') {
935
+ if (options.json)
936
+ finish(onboarding, null);
937
+ else
938
+ console.log(formatOnboarding(onboarding));
939
+ process.exitCode = onboardingExitCode(onboarding);
940
+ return;
941
+ }
942
+ note('The app is onboarded for this base.');
943
+ submitted = await submitCapture(capture, auth.token, found);
944
+ note(submitted.line);
945
+ }
946
+ if (submitted.runId === null) {
947
+ finish(onboarding, null);
948
+ process.exitCode = 1;
949
+ return;
950
+ }
951
+ const runId = submitted.runId;
952
+ view = await readWithRetries(() => readCrawlRun(runId, identity, auth.token));
953
+ }
954
+ if (options.wait !== false && briefOf(view) === null && !TERMINAL.has(reportedStatus(view)))
955
+ view = await waitForBrief(view, identity, auth.token);
956
+ const brief = briefOf(view);
957
+ if (options.json)
958
+ finish(onboarding, view);
959
+ else if (brief !== null)
960
+ console.log(formatBrief(brief));
961
+ else if (!TERMINAL.has(reportedStatus(view)))
962
+ console.log(`The brief is not ready yet: ${currentStep(view)}. Run \`haystack pre-verify\` again to see it.`);
963
+ // What gets a brief now depends on how the run ended (Bugbot): a `prepared` one is crawled by `haystack verify`, whose answers
964
+ // carry it; a completed one is never run again, so only a new capture (any edit) is prepared afresh; an incomplete or cancelled
965
+ // one is asked for again by the next pre-verify.
966
+ else
967
+ console.log(`Run ${view.runId} ended (${view.status}) without a brief${view.error ? `: ${errorWords(view.error)}` : ''}. `
968
+ + (view.status === 'prepared' ? 'Run `haystack verify` to crawl the change; its results carry the brief.'
969
+ : 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
+ : 'Run `haystack pre-verify` again to prepare it again.'));
971
+ if (brief === null && TERMINAL.has(reportedStatus(view)))
972
+ process.exitCode = 2;
973
+ }
716
974
  export async function verifyCommand(options) {
717
975
  // In --json mode stdout carries the one document; every other line goes to stderr.
718
976
  const note = (line) => { if (options.json)
719
977
  console.error(line);
720
978
  else
721
979
  console.log(line); };
722
- const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes), poolOption(options.pool));
980
+ const agent = agentDirection(options);
981
+ const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes), poolOption(options.pool), agent);
723
982
  const request = capture.derivation.request;
724
983
  const change = checkedChange(request.baseCommit, request.workCommit);
725
984
  if (!request.crawl) {
@@ -745,8 +1004,9 @@ export async function verifyCommand(options) {
745
1004
  let view = await readWithRetries(() => readCrawl(identity, auth.token));
746
1005
  let onboarding = null;
747
1006
  const stale = view === null ? null : staleCrawl(view, identity);
748
- // A pinned pool is its own crawl (amendment 16), which the capture read cannot tell apart: always submit, and follow that run.
749
- if (view === null || stale !== null || options.pool !== undefined) {
1007
+ // A pinned pool (amendment 16), an intent or ideas (amendment 17) make their own crawl, which the capture read cannot tell
1008
+ // apart: always submit, and follow that run (the same ones again are the same run).
1009
+ if (view === null || stale !== null || options.pool !== undefined || agent.intent !== undefined || agent.ideas !== undefined) {
750
1010
  const found = view === null || stale === null ? null : { view, stale };
751
1011
  let submitted = await submitCapture(capture, auth.token, found);
752
1012
  note(submitted.line);
package/dist/index.js CHANGED
@@ -85,6 +85,7 @@ const editRulesCommand = lazy(() => import('./commands/rules.js'), 'editRulesCom
85
85
  const validateRulesCommand = lazy(() => import('./commands/rules.js'), 'validateRulesCommand');
86
86
  const systemMapValidateCommand = lazy(() => import('./commands/system-map.js'), 'systemMapValidateCommand');
87
87
  const verifyCommand = lazy(() => import('./commands/verify.js'), 'verifyCommand');
88
+ const preVerifyCommand = lazy(() => import('./commands/verify.js'), 'preVerifyCommand');
88
89
  const headlessLoginCommand = lazy(() => import('./commands/tokens.js'), 'headlessLoginCommand');
89
90
  const headlessLogoutCommand = lazy(() => import('./commands/tokens.js'), 'headlessLogoutCommand');
90
91
  const listTokensCommand = lazy(() => import('./commands/tokens.js'), 'listTokensCommand');
@@ -146,6 +147,7 @@ program
146
147
  .command('init')
147
148
  .description('Set this repository up for `haystack verify` and start onboarding the app')
148
149
  .option('-y, --yes', 'Make the changes without asking')
150
+ .option('--notes <file>', 'A JSON file (- for stdin) of what you know about running the app; see below')
149
151
  .option('--json', 'The result as one JSON document on stdout (see `haystack schema init`)')
150
152
  .addHelpText('after', `
151
153
  There is no setup file: Haystack works out how to run the app from what the
@@ -161,6 +163,18 @@ Then it starts onboarding the app, so it is usually ready by the first
161
163
  \`haystack verify\`. Running it again changes only what is missing and shows
162
164
  where onboarding is (\`haystack verify onboarding\` shows that too).
163
165
 
166
+ --notes tells onboarding what a coding agent working here already knows, so it
167
+ does not have to work it out (a wrong guess at the start command can cost half
168
+ an hour). The file is JSON with any of three notes, each plain text:
169
+ run how production installs, builds and starts the app: the commands
170
+ and their directory, the port, the databases and services and their
171
+ versions, migrations, the environment variables it needs (names and
172
+ what they are for, never secret values)
173
+ signIn how a person gets an account and signs in, and the kinds of users
174
+ workflow what a signed-in user mainly does, step by step
175
+ Leave out what you do not know. Onboarding still proves everything it takes
176
+ from the notes. New notes start a new onboarding; the same notes again do not.
177
+
164
178
  Exit codes:
165
179
  0 set up: onboarding is running or the app is ready
166
180
  1 the command failed
@@ -174,7 +188,7 @@ Exit codes:
174
188
  Examples:
175
189
  haystack init
176
190
  haystack init --yes
177
- haystack init --yes --json
191
+ haystack init --yes --json --notes haystack-notes.json
178
192
  `)
179
193
  .action(options => runPublicCommand(() => initCommand(options), options.json));
180
194
  program
@@ -231,6 +245,9 @@ const verify = program
231
245
  .option('--no-wait', 'Print the crawl\'s current state and return without waiting')
232
246
  .option('--minutes <n>', 'How long the crawl may take, 1-30 (default: .haystack.json crawl.minutes, else 3)')
233
247
  .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')
249
+ .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
+ .option('--ideas-file <path>', 'A JSON array of more ideas')
234
251
  .option('--json', 'The crawl as one JSON document on stdout (see `haystack schema verify`)')
235
252
  .addHelpText('after', `
236
253
  Run inside a git checkout; nothing needs to be committed or pushed. It captures
@@ -242,6 +259,12 @@ one it finds was cancelled, stopped before finishing or is being cancelled, it
242
259
  submits the capture (the service starts the crawl, or runs the stopped one
243
260
  again), says so, and follows that crawl.
244
261
 
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
264
+ explored before anything else, and the answer says what became of it: tried,
265
+ could not try (and why), or not finished in time. Other ideas make another
266
+ crawl of the same code; the same ones again follow the same crawl.
267
+
245
268
  A crawl builds the app with and without your change, starts from the changed
246
269
  code, explores outward with both side by side, and double-checks and judges
247
270
  every difference it can within its time: 3 minutes unless the repository's
@@ -282,6 +305,28 @@ Examples:
282
305
  haystack verify hosted status cv_<48 lowercase hex characters> --wait
283
306
  `)
284
307
  .action(options => runPublicCommand(() => verifyCommand(options), options.json));
308
+ program
309
+ .command('pre-verify')
310
+ .description('Before a check: what your change touches, and what could break in it')
311
+ .option('--repo <owner/repo>', 'GitHub repository (default: origin remote)')
312
+ .option('--account <login>', 'Use a specific saved Haystack account')
313
+ .option('--no-wait', 'Print what is known now and return without waiting for the brief')
314
+ .option('--json', 'The brief as one JSON document on stdout (see `haystack schema pre-verify`)')
315
+ .addHelpText('after', `
316
+ Run inside a git checkout, like haystack verify. It captures the checkout the
317
+ same way, builds and prepares the change when nothing has yet (the turn-end
318
+ hook usually has), and prints its brief: the functions the change touches, the
319
+ changed ones and those one step away first (every one is in --json), and
320
+ 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.
322
+
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>"
325
+
326
+ Exit codes: 0 with a brief; 2 when the run ended without one; 3 when the app's
327
+ onboarding is blocked; 1 when the command failed.
328
+ `)
329
+ .action(options => runPublicCommand(() => preVerifyCommand(options), options.json));
285
330
  /**
286
331
  * Options for a `verify` subcommand: its own values, plus any option the user
287
332
  * passed to an ancestor (`haystack verify --no-wait hosted status ...`).
@@ -889,7 +934,17 @@ program
889
934
  .option('--json', 'Output the answer and every consulted customer-facing source as JSON')
890
935
  .option('--session <id>', 'Continue a previous Ask Haystack session')
891
936
  .action((ref, question, options) => runPublicCommand(() => askHaystackCommand(ref, question, options), options.json));
892
- const telemetry = program.command('telemetry').description('Add privacy-safe production telemetry without an application SDK');
937
+ const telemetry = program.command('telemetry').description('Add privacy-safe production telemetry without an application SDK')
938
+ .addHelpText('after', `
939
+ Next.js bundles its server code, so instead of \`instrument\` add the CLI as a
940
+ dependency and wrap next.config (Next 15.4+, Turbopack or --webpack):
941
+ import { withHaystackTelemetry } from '@haystackeditor/cli/next';
942
+ export default withHaystackTelemetry(nextConfig);
943
+ \`next build\` then instruments git-tracked server source and prints the
944
+ NODE_OPTIONS=--require=... preload to start the server with. \`next start\`
945
+ loads next.config, so it needs the CLI as a production dependency; standalone
946
+ output (server.js) does not load next.config, so a dev dependency suffices.
947
+ `);
893
948
  telemetry
894
949
  .command('instrument <dist>')
895
950
  .description('Instrument compiled Node.js output without changing application source')
package/dist/schema.js CHANGED
@@ -20,10 +20,11 @@ export const SCHEMA_VERSIONS = {
20
20
  action: '1.0.0',
21
21
  'cloud-verifier': '1.0.0',
22
22
  'case-batch': '1.0.1',
23
- verify: '1.0.2',
24
- 'verify-answer': '1.0.0',
23
+ verify: '1.0.4',
24
+ 'pre-verify': '1.0.1',
25
+ 'verify-answer': '1.0.1',
25
26
  'verify-onboarding': '1.0.0',
26
- init: '1.0.0',
27
+ init: '1.0.1',
27
28
  login: '1.0.0',
28
29
  error: '1.0.0',
29
30
  };
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /**
3
+ * Bundler loader behind `withHaystackTelemetry` (./next.ts). It speaks the
4
+ * webpack loader API subset Turbopack also implements (getOptions, async,
5
+ * resourcePath) and is CommonJS because both bundlers' loader runners can
6
+ * require() it; the instrumenter itself is ESM and is imported on first use.
7
+ *
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.
12
+ */
13
+ const node_child_process_1 = require("node:child_process");
14
+ const node_crypto_1 = require("node:crypto");
15
+ const node_fs_1 = require("node:fs");
16
+ const node_path_1 = require("node:path");
17
+ // Must equal the directory names in ./next.ts (an ESM module this CommonJS
18
+ // file cannot import synchronously).
19
+ const MODULES_DIRECTORY = 'modules';
20
+ const UNPARSED_DIRECTORY = 'unparsed';
21
+ let telemetry = null;
22
+ const trackedByRoot = new Map();
23
+ function loadTelemetry() {
24
+ telemetry ??= import('../commands/telemetry.js').then(async (module) => {
25
+ await module.preloadBabel();
26
+ return module;
27
+ });
28
+ return telemetry;
29
+ }
30
+ /** Git-tracked files under the repository root, once per loader process. */
31
+ function trackedFiles(sourceRoot) {
32
+ let tracked = trackedByRoot.get(sourceRoot);
33
+ if (!tracked) {
34
+ const listed = (0, node_child_process_1.execFileSync)('git', ['ls-files', '-z'], {
35
+ cwd: sourceRoot,
36
+ encoding: 'utf8',
37
+ stdio: ['ignore', 'pipe', 'pipe'],
38
+ maxBuffer: 256 * 1024 * 1024,
39
+ });
40
+ tracked = new Set(listed.split('\0').filter(Boolean));
41
+ trackedByRoot.set(sourceRoot, tracked);
42
+ }
43
+ return tracked;
44
+ }
45
+ async function instrument(context, source, options) {
46
+ const relativePath = (0, node_path_1.relative)(options.sourceRoot, context.resourcePath);
47
+ if (!relativePath || relativePath.startsWith('..') || (0, node_path_1.isAbsolute)(relativePath))
48
+ return null;
49
+ const sourcePath = relativePath.split(node_path_1.sep).join('/');
50
+ if (sourcePath.split('/').includes('node_modules') || !trackedFiles(options.sourceRoot).has(sourcePath)) {
51
+ return null;
52
+ }
53
+ const result = (await loadTelemetry()).instrumentBundledModule(source, {
54
+ sourcePath,
55
+ controlChannel: options.controlChannel,
56
+ runtimeIntegrity: options.runtimeIntegrity,
57
+ });
58
+ if (result.kind === 'unchanged')
59
+ return null;
60
+ if (result.kind === 'unparsed') {
61
+ // Ship it as written and name it in the build's report: the bundler's
62
+ // compiler accepted syntax Babel does not, which is no reason to fail.
63
+ const record = { sourcePath, reason: result.reason };
64
+ writeSessionRecord(context, options, UNPARSED_DIRECTORY, sourcePath, source, record);
65
+ return null;
66
+ }
67
+ // A module with nothing to probe (only module-scope declarations, types)
68
+ // ships unchanged and adds nothing to the manifest.
69
+ if (result.sites.length === 0)
70
+ return null;
71
+ const record = { sourcePath, probes: result.probes, sites: result.sites };
72
+ writeSessionRecord(context, options, MODULES_DIRECTORY, sourcePath, source, record);
73
+ return result.code;
74
+ }
75
+ function writeSessionRecord(context, options, directoryName, sourcePath, source, record) {
76
+ // The record must be written by every build that ships this module, so a
77
+ // persisted webpack cache entry may not stand in for running the loader.
78
+ context.cacheable?.(false);
79
+ // One record per (path, text). Next compiles one file once per server layer
80
+ // (RSC, SSR), possibly concurrently; identical input gives identical bytes,
81
+ // and the rename keeps a reader from seeing a half-written file.
82
+ const name = (0, node_crypto_1.createHash)('sha256').update(sourcePath).update('\0').update(source).digest('hex');
83
+ const directory = (0, node_path_1.join)(options.sessionDirectory, directoryName);
84
+ (0, node_fs_1.mkdirSync)(directory, { recursive: true });
85
+ const temporary = (0, node_path_1.join)(directory, `.${name}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
86
+ (0, node_fs_1.writeFileSync)(temporary, JSON.stringify(record));
87
+ (0, node_fs_1.renameSync)(temporary, (0, node_path_1.join)(directory, `${name}.json`));
88
+ }
89
+ 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))));
92
+ }
93
+ module.exports = haystackTelemetryNextLoader;
@@ -0,0 +1,33 @@
1
+ import { type FileProbeCounts, type InstrumentedSite } from '../commands/telemetry.js';
2
+ /** Options the config hands every loader invocation; JSON for Turbopack. */
3
+ export interface NextTelemetryLoaderOptions {
4
+ /** Repository root (git top level); every site path is relative to it. */
5
+ sourceRoot: string;
6
+ /** This build's hand-off directory between loaders and the post-compile step. */
7
+ sessionDirectory: string;
8
+ controlChannel: string;
9
+ runtimeIntegrity: string;
10
+ }
11
+ /** One instrumented module, as a loader records it for the post-compile step. */
12
+ export interface NextTelemetryModuleRecord {
13
+ sourcePath: string;
14
+ probes: FileProbeCounts;
15
+ sites: InstrumentedSite[];
16
+ }
17
+ /** A server module shipped uninstrumented because Babel could not parse it. */
18
+ export interface NextTelemetryUnparsedRecord {
19
+ sourcePath: string;
20
+ reason: string;
21
+ }
22
+ /**
23
+ * Wrap a Next config (object or function). Every phase but the production
24
+ * build returns the application's config unchanged.
25
+ *
26
+ * Generic so the application's own types pass through: a typed
27
+ * `(phase, { defaultConfig }: { defaultConfig: NextConfig }) => NextConfig`
28
+ * keeps its context type (a fixed `unknown` parameter would reject it under
29
+ * strictFunctionTypes), and the result is Next's `NextConfig` when that is
30
+ * what went in, without this package importing `next`.
31
+ */
32
+ export declare function withHaystackTelemetry<Config extends object, Context>(nextConfig: (phase: string, context: Context) => Config | Promise<Config>): (phase: string, context: Context) => Promise<Config>;
33
+ export declare function withHaystackTelemetry<Config extends object>(nextConfig: Config): (phase: string, context?: unknown) => Promise<Config>;