@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 +6 -3
- package/dist/commands/capture-brief.js +16 -6
- package/dist/commands/crawl-contract.js +2 -0
- package/dist/commands/init.js +3 -2
- package/dist/commands/verify-flows.js +62 -0
- package/dist/commands/verify.js +182 -6
- package/dist/index.js +32 -4
- package/dist/schema.js +2 -1
- package/package.json +1 -1
- package/schemas/verify-flow.v1.json +181 -0
- package/schemas/verify.v2.json +120 -1
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
|
|
47
|
-
|
|
48
|
-
break, then asks you to
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
53
|
-
|
|
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 =>
|
|
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;
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/commands/verify.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/schemas/verify.v2.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"properties": {
|
|
13
13
|
"schema_version": {
|
|
14
|
-
"const": "2.1.
|
|
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
|
}
|