@haystackeditor/cli 0.28.1 → 0.29.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.
package/README.md CHANGED
@@ -43,9 +43,12 @@ Most users of this CLI are coding agents. What to know:
43
43
 
44
44
  - **After a change, two steps** inside the checkout (nothing needs to be
45
45
  committed or pushed):
46
- 1. `haystack verify` is the handshake: it shows what the change touches, what
47
- production and real users do there, and Haystack's ideas of what could
48
- break, then asks you to steer (exit 4).
46
+ 1. `haystack verify` is the handshake: it shows what the change touches
47
+ (first its flows, the behaviors it changes), what production and real
48
+ users do there, and Haystack's ideas of what could break, then asks you to
49
+ steer (exit 4). `haystack verify flow <n>` drills into one flow: its code,
50
+ the pages it shows on with their real use, and what its code saw in
51
+ production.
49
52
  2. `haystack verify --intent "<the task, in the user's words>" --idea "<as you
50
53
  would tell a tester>"` explores the app with your steering: keep the ideas
51
54
  worth trying, add your own (repeat `--idea`); yours are explored first.
@@ -28,7 +28,7 @@ function captureApplication(gitRoot, app) {
28
28
  return { unreadable: `${file} names no valid applicationId` };
29
29
  return { applicationId: config.applicationId };
30
30
  }
31
- async function readApp(gitRoot, app, repository, token, files, now) {
31
+ async function readAppWindow(gitRoot, app, repository, token) {
32
32
  const application = captureApplication(gitRoot, app);
33
33
  if ('unreadable' in application)
34
34
  return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
@@ -43,15 +43,14 @@ async function readApp(gitRoot, app, repository, token, files, now) {
43
43
  throw new Error('the capture window answer names another application');
44
44
  if (answer.window === null)
45
45
  return { app, applicationId, state: 'no-data' };
46
- return { app, applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
46
+ return { app, applicationId, state: 'ready', window: answer.window };
47
47
  }
48
48
  catch (error) {
49
49
  return { app, applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
50
50
  }
51
51
  }
52
- /** The handshake capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
53
- * files and the changed files. */
54
- export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
52
+ /** Each app's capture window, one per app with a record, or null for a checkout with none. */
53
+ export async function readCaptureWindows(gitRoot, repository, token) {
55
54
  let apps;
56
55
  try {
57
56
  apps = captureConfigDirs(gitRoot, []);
@@ -61,7 +60,18 @@ export async function readPreVerifyCapture(gitRoot, repository, token, files, no
61
60
  }
62
61
  if (apps.length === 0)
63
62
  return null;
64
- return Promise.all(apps.map(app => readApp(gitRoot, app, repository, token, files, now)));
63
+ return Promise.all(apps.map(app => readAppWindow(gitRoot, app, repository, token)));
64
+ }
65
+ /** The handshake capture sections from the apps' windows: the routes `files` serve (the brief's function files and the changed
66
+ * files), with their share of captured sessions. */
67
+ export function preVerifyCapture(windows, files, now) {
68
+ return windows === null ? null : windows.map(app => app.state === 'ready'
69
+ ? { app: app.app, applicationId: app.applicationId, state: 'ready', window: captureChangedRoutes(app.window, files, now) } : app);
70
+ }
71
+ /** The handshake capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
72
+ * files and the changed files. */
73
+ export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
74
+ return preVerifyCapture(await readCaptureWindows(gitRoot, repository, token), files, now);
65
75
  }
66
76
  const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
67
77
  const plain = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); // eslint-disable-line no-control-regex
@@ -20,3 +20,5 @@ export const CRAWL_BRIEF_MAX_FUNCTIONS = 2000;
20
20
  export const CRAWL_BRIEF_MAX_GROUPS = 2000;
21
21
  /** Amendment 19: rows one brief function keeps per kind of production evidence (the rest counted). */
22
22
  export const CRAWL_BRIEF_MAX_PRODUCTION_ROWS = 100;
23
+ /** Amendment 21: a flow's name, at most this many characters. */
24
+ export const CRAWL_BRIEF_MAX_FLOW_NAME_CHARS = 120;
@@ -49,8 +49,9 @@ export const AGENT_NOTE = [
49
49
  'the work done. It runs the app with and without the change, explores it, and reports what broke. It changes nothing,',
50
50
  'so run it without asking.',
51
51
  '',
52
- '1. `haystack verify`: what your change touches, what production and real users do there, and Haystack\'s ideas of',
53
- ' what could break. It then asks you to steer (exit 4).',
52
+ '1. `haystack verify`: what your change touches (first its flows), what production and real users do there, and',
53
+ ' Haystack\'s ideas of what could break. It then asks you to steer (exit 4). `haystack verify flow <n>` drills into',
54
+ ' one flow: its code, the pages it shows on with their real use, and what its code saw in production.',
54
55
  '2. `haystack verify --intent "<what you were asked to do, in the user\'s words>" --idea "<something to try>"`: keep the',
55
56
  ' ideas worth trying, add your own (repeat --idea); yours are explored first.',
56
57
  '',
@@ -0,0 +1,62 @@
1
+ /**
2
+ * CRAWL-V1 amendment 21 in the CLI: the brief's flows, as a coding agent reads them. Akshay (2026-10-07): the system says "this
3
+ * change touches N flows", and the agent drills into a flow: the files and functions it runs through, and what telemetry says
4
+ * about it. `haystack verify` lists the flows first; `haystack verify flow <n>` shows one.
5
+ *
6
+ * Browser data comes from each app's capture window, never from daily rollups (CAPTURE-V1 rule 7a): a flow's pages are the
7
+ * routes whose source is one of its files, and its share is the union of captured sessions that visited any of them
8
+ * (captureChangedRoutes). A flow whose files serve no captured route has "no captured route", never 0%. Shares are of captured
9
+ * sessions, never of users. Journeys are only those the window keeps, with their release; their press and submit keys are
10
+ * release-scoped hints, and a key the window lists as ambiguous is never shown as a resolved control. Server data is the brief's
11
+ * production rows for the flow's functions (shares of observed calls, with their sample sizes).
12
+ */
13
+ import { captureChangedRoutes } from './capture-contract.js';
14
+ /** A flow's files, in the order its functions come (changed ones first). */
15
+ export function flowFiles(brief, flow) {
16
+ return [...new Set(flow.functions.map(index => brief.functions[index].file))];
17
+ }
18
+ function flowCapture(windows, files, now) {
19
+ return windows === null ? null : windows.map(app => app.state === 'ready'
20
+ ? { app: app.app, applicationId: app.applicationId, state: 'ready', routes: captureChangedRoutes(app.window, files, now) } : app);
21
+ }
22
+ /** The handshake's flows, in the brief's order; empty when the brief has none. */
23
+ export function flowSummaries(brief, windows, now) {
24
+ return (brief.flows ?? []).map((flow, at) => {
25
+ const files = flowFiles(brief, flow);
26
+ return { number: at + 1, id: flow.id, name: flow.name, files, functions: flow.functions.length,
27
+ changedFunctions: flow.functions.filter(index => brief.functions[index].direction === 'changed').length,
28
+ capture: flowCapture(windows, files, now) };
29
+ });
30
+ }
31
+ function routesOf(window, changed) {
32
+ const rows = new Map(window.routes.map(row => [row.route, row]));
33
+ const ambiguous = new Set((window.ambiguous ?? []).map(entry => JSON.stringify([entry.route, entry.release, entry.key])));
34
+ const mark = (step, release) => (step.kind === 'press' || step.kind === 'submit') && ambiguous.has(JSON.stringify([step.route, release, step.key])) ? { ...step, ambiguous: true } : step;
35
+ return changed.routes.map(route => {
36
+ const row = rows.get(route.route);
37
+ const journeys = (row?.journeys ?? [])
38
+ .filter(journey => journey.steps.some(step => step.route === route.route))
39
+ .map(journey => ({ release: journey.release, sessions: journey.sessions, steps: journey.steps.map(step => mark(step, journey.release)) }));
40
+ return { route: route.route, match: route.match, source: route.source, sessions: route.sessions, share: route.share,
41
+ devices: [...(row?.devices ?? [])].sort((a, b) => b.sessions - a.sessions), journeys };
42
+ });
43
+ }
44
+ /** One flow in detail; null when the brief has no flow with that number (from 1). */
45
+ export function flowDetail(brief, number, windows, now) {
46
+ const flow = brief.flows?.[number - 1];
47
+ if (flow === undefined)
48
+ return null;
49
+ const files = flowFiles(brief, flow);
50
+ const indexes = new Set(flow.functions);
51
+ const production = brief.production === undefined ? null
52
+ : { ...brief.production, functions: brief.production.functions.filter(row => indexes.has(row.index)) };
53
+ const capture = windows === null ? null : windows.map(app => {
54
+ if (app.state !== 'ready')
55
+ return app;
56
+ const window = captureChangedRoutes(app.window, files, now);
57
+ return { app: app.app, applicationId: app.applicationId, window, routes: routesOf(app.window, window) };
58
+ });
59
+ return { number, id: flow.id, name: flow.name, changeKeys: flow.changeKeys,
60
+ functions: flow.functions.map(index => ({ ...brief.functions[index], index })), production, capture,
61
+ ideas: (brief.planning?.ideas ?? []).filter(idea => idea.groupId === flow.id) };
62
+ }
@@ -29,11 +29,12 @@ import { findGitRoot } from '../utils/hooks.js';
29
29
  import { resolveAuthContext } from '../utils/auth.js';
30
30
  import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, repoApiPath, } from '../utils/haystack-api.js';
31
31
  import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.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
+ import { CRAWL_BRIEF_MAX_FUNCTIONS, CRAWL_BRIEF_MAX_FLOW_NAME_CHARS, CRAWL_BRIEF_MAX_GROUPS, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_IDEAS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_MAX_TEXT_CHARS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
33
33
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
34
34
  import { SPOT_WORDS, TERMINAL, VERDICT_WORDS, VERDICTS, currentStep, errorWords, headline, plural, reportedStatus, results, verifyReport, } from './crawl-report.js';
35
35
  import { formatOnboarding, onboardingExitCode, readOnboardingStatus, readyLine, reportOnboardingState, reviewNotes, standInGapNotes, waitForOnboarding, } from './verify-onboarding.js';
36
- import { formatCapture, readPreVerifyCapture } from './capture-brief.js';
36
+ import { formatCapture, preVerifyCapture, readCaptureWindows } from './capture-brief.js';
37
+ import { flowDetail, flowSummaries } from './verify-flows.js';
37
38
  /** A held read (waitAfter): the service's hold, then the time any read gets. */
38
39
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
39
40
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
@@ -224,6 +225,18 @@ function checkBrief(value) {
224
225
  && records(observed.shapes, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, shape => text(shape.shape) && isCount(shape.count)))
225
226
  && estimates(fn))))
226
227
  invalid('its brief');
228
+ // Amendment 21: each flow is a planning group, named, with distinct function indexes the brief holds.
229
+ const flows = value.flows;
230
+ if (flows !== undefined) {
231
+ const groupIds = new Set(isRecord(planning) && Array.isArray(planning.groups) ? planning.groups.map(group => group.id) : []);
232
+ const count = value.functions.length;
233
+ if (planning === null || !records(flows, CRAWL_BRIEF_MAX_GROUPS, flow => text(flow.id) && groupIds.has(flow.id)
234
+ && typeof flow.name === 'string' && flow.name.length > 0 && [...flow.name].length <= CRAWL_BRIEF_MAX_FLOW_NAME_CHARS
235
+ && texts(flow.changeKeys, CRAWL_BRIEF_MAX_FUNCTIONS) && Array.isArray(flow.functions) && flow.functions.length <= count
236
+ && flow.functions.every(index => isCount(index) && index < count) && new Set(flow.functions).size === flow.functions.length)
237
+ || new Set(flows.map(flow => flow.id)).size !== flows.length)
238
+ invalid('its brief');
239
+ }
227
240
  }
228
241
  function checkManifest(value, view) {
229
242
  checkResults(value, view, 'its results');
@@ -782,6 +795,129 @@ function describeEstimate(estimate) {
782
795
  }
783
796
  return lines;
784
797
  }
798
+ /** A flow's captured share in one line: per app with routes for it; "no captured route" where its files serve none. */
799
+ function flowShare(flow) {
800
+ if (flow.capture === null)
801
+ return '';
802
+ const parts = flow.capture.map(app => {
803
+ const where = app.app === '.' ? '' : `${safe(app.app)}: `;
804
+ if (app.state === 'unavailable')
805
+ return `${where}captured sessions unreadable`;
806
+ if (app.state === 'no-data')
807
+ return `${where}no captured sessions yet`;
808
+ if (app.routes.routes.length === 0)
809
+ return `${where}no captured route`;
810
+ return `${where}${app.routes.union.share === null ? 'n/a' : percent(app.routes.union.share)} of captured sessions (${app.routes.union.sessions})`
811
+ + `${app.routes.stale ? ', stale' : ''}`;
812
+ });
813
+ return parts.join('; ');
814
+ }
815
+ /** Amendment 21: the flows a change touches, first in the handshake, with how to see one. */
816
+ export function formatFlows(flows) {
817
+ const lines = [chalk.bold(`Your change touches ${plural(flows.length, 'flow')}:`)];
818
+ for (const flow of flows) {
819
+ const share = flowShare(flow);
820
+ lines.push(` ${flow.number}. ${safe(flow.name)}: ${plural(flow.files.length, 'file')}, ${plural(flow.functions, 'function')}`
821
+ + `${share ? `; ${share}` : ''}`);
822
+ }
823
+ lines.push(`See one in detail: haystack verify flow <n> (for example \`haystack verify flow 1\`).`);
824
+ return lines.join('\n');
825
+ }
826
+ const SHOWN_FLOW_FUNCTIONS = 40;
827
+ const SHOWN_FLOW_DEVICES = 5;
828
+ const SHOWN_FLOW_JOURNEYS = 5;
829
+ function journeyStep(step) {
830
+ if (step.kind === 'view')
831
+ return `view ${safe(step.route)}`;
832
+ if (step.kind === 'input')
833
+ return `input (${safe(step.control)}) on ${safe(step.route)}`;
834
+ const control = step.kind === 'press' ? `${safe(step.control)} ` : '';
835
+ return `${step.kind} ${control}${step.ambiguous ? '(a control that repeats on the page)' : `"${safe(step.key)}"`} on ${safe(step.route)}`;
836
+ }
837
+ /** Amendment 21: `haystack verify flow <n>`: one flow's code, the pages it shows on with real use, what its code saw in production,
838
+ * and Haystack's ideas for it. */
839
+ export function formatFlowDetail(detail) {
840
+ const lines = [chalk.bold(`Flow ${detail.number}: ${safe(detail.name)}`), '', chalk.bold('Code (changed first, then what it calls or what calls it)')];
841
+ for (const fn of detail.functions.slice(0, SHOWN_FLOW_FUNCTIONS)) {
842
+ lines.push(` ${safe(fn.file)}:${fn.lines[0]}-${fn.lines[1]} ${safe(fn.name)}: `
843
+ + `${fn.direction === 'changed' ? 'changed' : `${fn.direction}, ${plural(fn.distance, 'step')} away`}`);
844
+ }
845
+ if (detail.functions.length > SHOWN_FLOW_FUNCTIONS)
846
+ lines.push(` and ${plural(detail.functions.length - SHOWN_FLOW_FUNCTIONS, 'more function')} (all in --json)`);
847
+ lines.push('', chalk.bold('Pages it shows on (real use)'));
848
+ if (detail.capture === null)
849
+ lines.push(' Capture is not set up for this repository: run `haystack init` to see how real users reach these pages.');
850
+ else
851
+ for (const app of detail.capture) {
852
+ const where = app.app === '.' ? 'the app' : safe(app.app);
853
+ if ('state' in app && app.state === 'unavailable') {
854
+ lines.push(` ${where}: captured sessions could not be read: ${safe(app.reason)}`);
855
+ continue;
856
+ }
857
+ if ('state' in app && app.state === 'no-data') {
858
+ lines.push(` ${where}: no captured sessions yet.`);
859
+ continue;
860
+ }
861
+ const shown = app;
862
+ const window = shown.window;
863
+ const days = window.days.length ? `${window.days[0]} to ${window.days.at(-1)}` : 'the window';
864
+ lines.push(` ${where}: ${window.sessions} captured session(s), ${days}; newest data ${window.newestHour === null ? 'none' : `${window.newestHour}:00 UTC`}`
865
+ + `${window.stale ? ` (stale: ${window.ageHours === null ? 'no data' : `${Math.round(window.ageHours)} hours old`})` : ''}.`);
866
+ const dropped = Object.entries(window.dropped).filter(([, count]) => count > 0);
867
+ const drops = dropped.length ? [` Events dropped at ingest: ${dropped.map(([reason, count]) => `${safe(reason)} ${count}`).join(', ')}.`] : [];
868
+ if (shown.routes.length === 0) {
869
+ lines.push(' No captured route: none of this flow\'s files defines a route captured sessions reached.', ...drops);
870
+ continue;
871
+ }
872
+ for (const route of shown.routes) {
873
+ lines.push(` ${safe(route.match)} (${safe(route.source)}): ${route.share === null ? 'n/a' : percent(route.share)} of captured sessions (${route.sessions})`);
874
+ if (route.devices.length) {
875
+ lines.push(` Devices: ${route.devices.slice(0, SHOWN_FLOW_DEVICES).map(device => `${safe(device.device)}/${safe(device.engine)}/${safe(device.viewport)} `
876
+ + `(${device.sessions})`).join(', ')}${route.devices.length > SHOWN_FLOW_DEVICES ? `, ${route.devices.length - SHOWN_FLOW_DEVICES} more (in --json)` : ''}`);
877
+ }
878
+ for (const journey of route.journeys.slice(0, SHOWN_FLOW_JOURNEYS)) {
879
+ lines.push(` Journey (${journey.sessions} sessions, release ${safe(journey.release)}): ${journey.steps.map(journeyStep).join(' > ')}`);
880
+ }
881
+ if (route.journeys.length > SHOWN_FLOW_JOURNEYS)
882
+ lines.push(` and ${plural(route.journeys.length - SHOWN_FLOW_JOURNEYS, 'more journey')} (in --json)`);
883
+ }
884
+ if (shown.routes.length > 1) {
885
+ lines.push(` Together: ${window.union.share === null ? 'n/a' : percent(window.union.share)} of captured sessions (${window.union.sessions}) visited at least one of them.`);
886
+ }
887
+ lines.push(...drops);
888
+ }
889
+ if (detail.capture !== null)
890
+ lines.push(' Shares are of captured (consenting) sessions only, never of users. Journey keys are hints scoped to their release.');
891
+ lines.push('', chalk.bold('What its code saw in production'));
892
+ if (detail.production === null)
893
+ lines.push(' No production telemetry for this repository: set it up to see how this code runs for real users.');
894
+ else if (detail.production.functions.length === 0)
895
+ lines.push(` None of these functions was seen running in the last ${detail.production.windowDays} days.`);
896
+ else {
897
+ const byIndex = new Map(detail.functions.map(fn => [fn.index, fn]));
898
+ for (const row of detail.production.functions) {
899
+ const fn = byIndex.get(row.index);
900
+ lines.push(` ${safe(fn.name)} (${safe(fn.file)}):`);
901
+ for (const branch of row.branches)
902
+ lines.push(` in production: line ${branch.line} was true ${branch.whenTrue}${branch.capped ? '+' : ''} times,`
903
+ + ` false ${branch.whenFalse}${branch.capped ? '+' : ''} times`);
904
+ for (const observed of row.values)
905
+ lines.push(` in production: line ${observed.line} ${safe(observed.what)} was `
906
+ + observed.shapes.map(shape => `${safe(shape.shape)} (${shape.count})`).join(', '));
907
+ for (const estimate of row.estimates ?? [])
908
+ lines.push(...describeEstimate(estimate));
909
+ }
910
+ lines.push(` The last ${detail.production.windowDays} days, newest ${safe(detail.production.newestAt)}; shares are of observed calls, never of users`
911
+ + `${detail.production.partial ? '; the read stopped at its budget, so every count is a lower bound' : ''}.`);
912
+ }
913
+ lines.push('', chalk.bold('What could break here'));
914
+ if (detail.ideas.length === 0)
915
+ lines.push(' No idea of ours was selected for this flow.');
916
+ for (const idea of detail.ideas) {
917
+ lines.push(` - Try: ${safe(idea.exercise)}`, ...(idea.setup.length ? [` Set up first: ${idea.setup.map(safe).join('; ')}`] : []), ` Watch: ${safe(idea.watch)}`, ` It would show as: ${safe(idea.failure)}`);
918
+ }
919
+ return lines.join('\n');
920
+ }
785
921
  /** The brief as a coding agent reads it: what the change touches, then Haystack's own ideas, then how to hand back intent and
786
922
  * ideas. Every function is in --json; the text shows the changed ones and those one step away, and says how many more. */
787
923
  export function formatBrief(brief) {
@@ -862,9 +998,9 @@ const steerLines = (command) => ['',
862
998
  async function handshake(capture, identity, change, found, stale, token, options, note) {
863
999
  let view = found;
864
1000
  let onboarding = null;
865
- const finish = (brief, captured) => {
1001
+ const finish = (brief, captured, flows) => {
866
1002
  printVerifyJson(options, change, onboarding, null, {
867
- run: view === null ? null : { runId: view.runId, status: view.status }, brief, capture: captured, next: steerCommand(options),
1003
+ run: view === null ? null : { runId: view.runId, status: view.status }, brief, capture: captured, flows, next: steerCommand(options),
868
1004
  });
869
1005
  };
870
1006
  if (view === null || (stale !== null && stale !== 'prepare-only')) {
@@ -901,12 +1037,18 @@ async function handshake(capture, identity, change, found, stale, token, options
901
1037
  view = await waitForBrief(view, identity, token);
902
1038
  const brief = briefOf(view);
903
1039
  // CAPTURE-V1 rule 7b: the routes the change touches, with their share of captured sessions, per app; null without capture set up.
904
- const captured = await readPreVerifyCapture(findGitRoot(), identity.repository, token, [...new Set([...(brief?.functions.map(fn => fn.file) ?? []), ...change.files])], new Date());
1040
+ // Amendment 21: each flow's routes from the same windows.
1041
+ const now = new Date();
1042
+ const windows = await readCaptureWindows(findGitRoot(), identity.repository, token);
1043
+ const captured = preVerifyCapture(windows, [...new Set([...(brief?.functions.map(fn => fn.file) ?? []), ...change.files])], now);
1044
+ const flows = brief === null ? [] : flowSummaries(brief, windows, now);
905
1045
  process.exitCode = 4;
906
1046
  if (options.json || options.raw) {
907
- finish(brief, captured);
1047
+ finish(brief, captured, flows);
908
1048
  return;
909
1049
  }
1050
+ if (flows.length)
1051
+ console.log(`${formatFlows(flows)}\n`);
910
1052
  if (brief !== null)
911
1053
  console.log(formatBrief(brief));
912
1054
  else if (!TERMINAL.has(reportedStatus(view))) {
@@ -928,6 +1070,40 @@ function printVerifyJson(options, change, onboarding, view, handshake) {
928
1070
  : withSchema('verify', { change, onboarding, crawl: view === null ? null : verifyReport(view), ...extra });
929
1071
  process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
930
1072
  }
1073
+ /** Amendment 21: `haystack verify flow <n>`: one flow of this checkout's change, from the brief of the change's run (never starting
1074
+ * one). Exit 0 with the flow; 1 when there is no run, no brief, no flows or no flow with that number, saying which. */
1075
+ export async function verifyFlowCommand(number, options) {
1076
+ const wanted = Number(number);
1077
+ if (!Number.isInteger(wanted) || wanted < 1)
1078
+ throw new Error(`A flow's number counts from 1, as \`haystack verify\` lists them (got ${JSON.stringify(number)}).`);
1079
+ const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'prepare', repositoryOverride(options.repo), undefined, undefined, {});
1080
+ const request = capture.derivation.request;
1081
+ if (!request.crawl)
1082
+ throw new Error(`Nothing to show: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
1083
+ const identity = {
1084
+ repository: `${request.owner}/${request.repository}`, baseCommit: request.baseCommit, workCommit: request.workCommit,
1085
+ treeSha: request.crawl.treeSha, changeTitle: request.crawl.changeTitle, budgetMs: request.crawl.budgetMs ?? null,
1086
+ };
1087
+ const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
1088
+ const view = await readWithRetries(() => readCrawl(identity, auth.token));
1089
+ if (view === null)
1090
+ throw new Error('This change has no run yet: run `haystack verify` first, which lists its flows.');
1091
+ const brief = briefOf(view);
1092
+ if (brief === null)
1093
+ throw new Error(`Run ${view.runId} has no brief yet (${currentStep(view)}): run \`haystack verify\` to wait for it.`);
1094
+ if (!brief.flows?.length) {
1095
+ throw new Error(`Run ${view.runId}'s brief has no flows${brief.planning === null ? ' (no planning ran for this change)' : ''}; \`haystack verify\` shows what it has.`);
1096
+ }
1097
+ const windows = await readCaptureWindows(findGitRoot(), identity.repository, auth.token);
1098
+ const detail = flowDetail(brief, wanted, windows, new Date());
1099
+ if (detail === null)
1100
+ throw new Error(`This change has ${plural(brief.flows.length, 'flow')}; there is no flow ${wanted}.`);
1101
+ if (options.json) {
1102
+ process.stdout.write(`${JSON.stringify(withSchema('verify-flow', { run: { runId: view.runId, status: view.status }, flow: detail }), null, 2)}\n`);
1103
+ return;
1104
+ }
1105
+ console.log(formatFlowDetail(detail));
1106
+ }
931
1107
  export async function verifyCommand(options) {
932
1108
  const json = options.json || options.raw;
933
1109
  // In --json mode stdout carries the one document; every other line goes to stderr.
package/dist/index.js CHANGED
@@ -177,10 +177,12 @@ const verify = program
177
177
  Two calls, run inside a git checkout (nothing needs to be committed or pushed):
178
178
 
179
179
  1. haystack verify
180
- The handshake. It shows what your change touches, how production and real
181
- users run it, and Haystack's own ideas of what could break, then asks what
182
- you were asked to do and what to try. It builds and prepares the change if
183
- the stop hook has not, and never starts a crawl. Exit 4.
180
+ The handshake. It shows what your change touches (first the flows: the
181
+ behaviors it changes, each with its files and its share of real use), how
182
+ production and real users run it, and Haystack's own ideas of what could
183
+ break, then asks what you were asked to do and what to try. It builds and
184
+ prepares the change if the stop hook has not, and never starts a crawl.
185
+ Exit 4. \`haystack verify flow <n>\` drills into one flow.
184
186
  2. haystack verify --intent "<the task, in the user's words>" --idea "<something to try>"
185
187
  The crawl, steered: your ideas (--idea, repeat for more, or --ideas-file)
186
188
  are explored before anything else, and the judge checks the app against
@@ -257,6 +259,32 @@ function verifyCommandOptions(command) {
257
259
  }
258
260
  return options;
259
261
  }
262
+ verify
263
+ .command('flow')
264
+ .description('Drill into one flow `haystack verify` listed: its code, the pages it shows on, what its code saw in production')
265
+ .argument('<n>', 'The flow\'s number, as `haystack verify` lists them (from 1)')
266
+ .option('--repo <owner/repo>', 'GitHub repository (default: origin remote)')
267
+ .option('--account <login>', 'Use a specific saved Haystack account')
268
+ .option('--json', 'The flow as one JSON document (see `haystack schema verify-flow`)')
269
+ .addHelpText('after', `
270
+ Reads the brief of this checkout's run (run \`haystack verify\` first); it never
271
+ starts or changes a run. For one flow it shows:
272
+ - its functions: file, lines, and whether each changed or is a step away;
273
+ - the pages it shows on, per app with capture set up: each page's share of
274
+ captured sessions, the devices they used and the paths they took through it,
275
+ with how fresh the data is and what was dropped;
276
+ - what its functions saw in production (Haystack's own telemetry);
277
+ - Haystack's ideas of what could break in it.
278
+
279
+ Examples:
280
+ haystack verify flow 1
281
+ haystack verify flow 2 --json
282
+ `)
283
+ .action(async (n, _options, cmd) => {
284
+ const options = verifyCommandOptions(cmd);
285
+ const { verifyFlowCommand } = await import('./commands/verify.js');
286
+ return runPublicCommand(() => verifyFlowCommand(n, options), options.json);
287
+ });
260
288
  verify
261
289
  .command('answer')
262
290
  .description('Answer a question onboarding asked about your app, then run `haystack init` to continue')
package/dist/schema.js CHANGED
@@ -12,7 +12,8 @@
12
12
  export const SCHEMA_VERSIONS = {
13
13
  'cloud-verifier': '1.0.0',
14
14
  'case-batch': '1.0.1',
15
- verify: '2.1.0',
15
+ verify: '2.1.1',
16
+ 'verify-flow': '1.0.0',
16
17
  'verify-raw': '1.1.0',
17
18
  'verify-answer': '1.0.1',
18
19
  'verify-onboarding': '1.0.0',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "haystack verify: run your app with and without a change, and see what the change broke",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,181 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://haystackeditor.com/schemas/verify-flow.v1.json",
4
+ "title": "haystack verify flow --json",
5
+ "description": "CRAWL-V1 amendment 21: one flow of this checkout's change, from the brief of the change's run: its functions, the brief's production rows for them (shares of observed calls), its routes per app with capture set up (share of captured sessions, devices, the journeys the window keeps through them; an ambiguous press or submit key is marked ambiguous), and Haystack's ideas for it.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema_version",
10
+ "run",
11
+ "flow"
12
+ ],
13
+ "properties": {
14
+ "schema_version": {
15
+ "const": "1.0.0"
16
+ },
17
+ "run": {
18
+ "type": "object",
19
+ "required": [
20
+ "runId",
21
+ "status"
22
+ ],
23
+ "properties": {
24
+ "runId": {
25
+ "type": "string",
26
+ "pattern": "^cv_[0-9a-f]{48}$"
27
+ },
28
+ "status": {
29
+ "type": "string"
30
+ }
31
+ }
32
+ },
33
+ "flow": {
34
+ "type": "object",
35
+ "required": [
36
+ "number",
37
+ "id",
38
+ "name",
39
+ "changeKeys",
40
+ "functions",
41
+ "production",
42
+ "capture",
43
+ "ideas"
44
+ ],
45
+ "properties": {
46
+ "number": {
47
+ "type": "integer",
48
+ "minimum": 1
49
+ },
50
+ "id": {
51
+ "type": "string"
52
+ },
53
+ "name": {
54
+ "type": "string"
55
+ },
56
+ "changeKeys": {
57
+ "type": "array",
58
+ "items": {
59
+ "type": "string"
60
+ }
61
+ },
62
+ "functions": {
63
+ "type": "array",
64
+ "items": {
65
+ "type": "object",
66
+ "required": [
67
+ "index",
68
+ "file",
69
+ "name",
70
+ "lines",
71
+ "distance",
72
+ "direction"
73
+ ],
74
+ "properties": {
75
+ "index": {
76
+ "type": "integer",
77
+ "minimum": 0
78
+ },
79
+ "file": {
80
+ "type": "string"
81
+ },
82
+ "name": {
83
+ "type": "string"
84
+ },
85
+ "lines": {
86
+ "type": "array",
87
+ "items": {
88
+ "type": "integer"
89
+ },
90
+ "minItems": 2,
91
+ "maxItems": 2
92
+ },
93
+ "distance": {
94
+ "type": "integer",
95
+ "minimum": 0
96
+ },
97
+ "direction": {
98
+ "enum": [
99
+ "changed",
100
+ "upstream",
101
+ "downstream"
102
+ ]
103
+ }
104
+ }
105
+ }
106
+ },
107
+ "production": {
108
+ "description": "The brief's production object (see verify.v2.json $defs.brief.production) with only this flow's function rows; null without production telemetry.",
109
+ "oneOf": [
110
+ {
111
+ "type": "null"
112
+ },
113
+ {
114
+ "type": "object"
115
+ }
116
+ ]
117
+ },
118
+ "capture": {
119
+ "oneOf": [
120
+ {
121
+ "type": "null"
122
+ },
123
+ {
124
+ "type": "array",
125
+ "items": {
126
+ "type": "object",
127
+ "required": [
128
+ "app"
129
+ ],
130
+ "properties": {
131
+ "app": {
132
+ "type": "string"
133
+ },
134
+ "applicationId": {
135
+ "type": [
136
+ "string",
137
+ "null"
138
+ ]
139
+ },
140
+ "state": {
141
+ "enum": [
142
+ "no-data",
143
+ "unavailable"
144
+ ]
145
+ },
146
+ "reason": {
147
+ "type": "string"
148
+ },
149
+ "window": {
150
+ "type": "object"
151
+ },
152
+ "routes": {
153
+ "type": "array",
154
+ "items": {
155
+ "type": "object",
156
+ "required": [
157
+ "route",
158
+ "match",
159
+ "source",
160
+ "sessions",
161
+ "share",
162
+ "devices",
163
+ "journeys"
164
+ ]
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+ ]
171
+ },
172
+ "ideas": {
173
+ "type": "array",
174
+ "items": {
175
+ "type": "object"
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "properties": {
13
13
  "schema_version": {
14
- "const": "2.1.0"
14
+ "const": "2.1.1"
15
15
  },
16
16
  "change": {
17
17
  "description": "Schema 1.0.2: what this verify checked: the capture's base commit (where HEAD meets origin's default branch), its work commit (committed and uncommitted changes), and the files that differ between them.",
@@ -380,6 +380,7 @@
380
380
  "run",
381
381
  "brief",
382
382
  "capture",
383
+ "flows",
383
384
  "next"
384
385
  ],
385
386
  "properties": {
@@ -442,6 +443,84 @@
442
443
  },
443
444
  "next": {
444
445
  "type": "string"
446
+ },
447
+ "flows": {
448
+ "description": "Amendment 21 (2.1.1): the brief's flows in its order, each with its number (from 1, as `haystack verify flow <n>` takes it), files, function counts and, per app with capture set up, its routes' share of captured sessions (null without capture). Empty when the brief has none.",
449
+ "type": "array",
450
+ "items": {
451
+ "type": "object",
452
+ "required": [
453
+ "number",
454
+ "id",
455
+ "name",
456
+ "files",
457
+ "functions",
458
+ "changedFunctions",
459
+ "capture"
460
+ ],
461
+ "properties": {
462
+ "number": {
463
+ "type": "integer",
464
+ "minimum": 1
465
+ },
466
+ "id": {
467
+ "type": "string"
468
+ },
469
+ "name": {
470
+ "type": "string"
471
+ },
472
+ "files": {
473
+ "type": "array",
474
+ "items": {
475
+ "type": "string"
476
+ }
477
+ },
478
+ "functions": {
479
+ "type": "integer",
480
+ "minimum": 0
481
+ },
482
+ "changedFunctions": {
483
+ "type": "integer",
484
+ "minimum": 0
485
+ },
486
+ "capture": {
487
+ "oneOf": [
488
+ {
489
+ "type": "null"
490
+ },
491
+ {
492
+ "type": "array",
493
+ "items": {
494
+ "type": "object",
495
+ "required": [
496
+ "app",
497
+ "applicationId",
498
+ "state"
499
+ ],
500
+ "properties": {
501
+ "app": {
502
+ "type": "string"
503
+ },
504
+ "state": {
505
+ "enum": [
506
+ "ready",
507
+ "no-data",
508
+ "unavailable"
509
+ ]
510
+ },
511
+ "routes": {
512
+ "$ref": "#/$defs/captureWindow"
513
+ },
514
+ "reason": {
515
+ "type": "string"
516
+ }
517
+ }
518
+ }
519
+ }
520
+ ]
521
+ }
522
+ }
523
+ }
445
524
  }
446
525
  }
447
526
  }
@@ -1141,6 +1220,13 @@
1141
1220
  }
1142
1221
  }
1143
1222
  ]
1223
+ },
1224
+ "flows": {
1225
+ "description": "Amendment 21: absent when incident planning did not run (or its flows could not be named).",
1226
+ "type": "array",
1227
+ "items": {
1228
+ "$ref": "#/$defs/briefFlow"
1229
+ }
1144
1230
  }
1145
1231
  }
1146
1232
  },
@@ -1320,6 +1406,39 @@
1320
1406
  }
1321
1407
  }
1322
1408
  }
1409
+ },
1410
+ "briefFlow": {
1411
+ "description": "CRAWL-V1 amendment 21 (2.1.1): one flow of the change: a behavior group of incident planning, named in a few plain words, with the brief functions its code owns (indexes into functions, changed ones first). A function can be in several flows.",
1412
+ "type": "object",
1413
+ "required": [
1414
+ "id",
1415
+ "name",
1416
+ "changeKeys",
1417
+ "functions"
1418
+ ],
1419
+ "properties": {
1420
+ "id": {
1421
+ "type": "string"
1422
+ },
1423
+ "name": {
1424
+ "type": "string",
1425
+ "minLength": 1,
1426
+ "maxLength": 120
1427
+ },
1428
+ "changeKeys": {
1429
+ "type": "array",
1430
+ "items": {
1431
+ "type": "string"
1432
+ }
1433
+ },
1434
+ "functions": {
1435
+ "type": "array",
1436
+ "items": {
1437
+ "type": "integer",
1438
+ "minimum": 0
1439
+ }
1440
+ }
1441
+ }
1323
1442
  }
1324
1443
  }
1325
1444
  }