@crewhaus/tool-flow 0.7.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.
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Map an error to a stable class and a next action.
3
+ *
4
+ * Deciding "is this worth retrying?" is the single most common reason a
5
+ * harness spends a model turn on something that has a right answer. A 429
6
+ * with `Retry-After: 30` is not a judgement call, and neither is exit code
7
+ * 127. This turns the signals a failure actually carries — status, exit
8
+ * code, errno, the text — into one of a fixed set of classes and one of a
9
+ * fixed set of actions, so a loop can act on it directly.
10
+ *
11
+ * Nothing here reads the clock. `Retry-After` in its HTTP-date form is a
12
+ * point in time, and turning that into a wait needs a "now"; the caller
13
+ * supplies one or gets the date back unconverted. A classifier that quietly
14
+ * called `Date.now()` would return a different answer every second, which is
15
+ * exactly what this package promises not to do.
16
+ */
17
+ export const ERROR_CLASSES = [
18
+ "ok",
19
+ "transient",
20
+ "timeout",
21
+ "unavailable",
22
+ "rate_limited",
23
+ "quota",
24
+ "auth",
25
+ "permission",
26
+ "not_found",
27
+ "bad_request",
28
+ "conflict",
29
+ "too_large",
30
+ "unsupported",
31
+ "capacity",
32
+ "integrity",
33
+ "cancelled",
34
+ "redirect",
35
+ "bug",
36
+ "unknown",
37
+ ];
38
+ export const NEXT_ACTIONS = [
39
+ "continue",
40
+ "retry",
41
+ "retry_after",
42
+ "backoff",
43
+ "reauth",
44
+ "reduce_input",
45
+ "escalate",
46
+ "skip",
47
+ "fail",
48
+ ];
49
+ /**
50
+ * Classes whose default action is "try again" rather than "change something".
51
+ *
52
+ * This decides whether a `Retry-After` is allowed to upgrade a plain retry —
53
+ * it is not the same question as {@link Classification.retryable}, which
54
+ * follows the final action. A capacity failure is in here because some of
55
+ * them (EMFILE, EAGAIN) do clear on their own, but an OOM kill under the
56
+ * same class resolves to `escalate`, and then it is not retryable.
57
+ */
58
+ const RETRY_BY_DEFAULT = new Set([
59
+ "transient",
60
+ "timeout",
61
+ "unavailable",
62
+ "rate_limited",
63
+ "capacity",
64
+ ]);
65
+ /** The actions that mean "call it again". */
66
+ const RETRY_ACTIONS = new Set(["retry", "retry_after", "backoff"]);
67
+ const STATUS_TABLE = new Map([
68
+ [400, { class: "bad_request", action: "fail" }],
69
+ [401, { class: "auth", action: "reauth" }],
70
+ [402, { class: "quota", action: "escalate" }],
71
+ [403, { class: "permission", action: "escalate" }],
72
+ [404, { class: "not_found", action: "fail" }],
73
+ [405, { class: "bad_request", action: "fail" }],
74
+ [406, { class: "bad_request", action: "fail" }],
75
+ [408, { class: "timeout", action: "retry" }],
76
+ [409, { class: "conflict", action: "escalate" }],
77
+ [410, { class: "not_found", action: "fail" }],
78
+ [412, { class: "conflict", action: "escalate" }],
79
+ [413, { class: "too_large", action: "reduce_input" }],
80
+ [414, { class: "too_large", action: "reduce_input" }],
81
+ [415, { class: "unsupported", action: "fail" }],
82
+ [422, { class: "bad_request", action: "fail" }],
83
+ [423, { class: "conflict", action: "backoff" }],
84
+ [425, { class: "transient", action: "retry" }],
85
+ [428, { class: "bad_request", action: "fail" }],
86
+ [429, { class: "rate_limited", action: "retry_after" }],
87
+ [431, { class: "too_large", action: "reduce_input" }],
88
+ [451, { class: "permission", action: "fail" }],
89
+ [500, { class: "transient", action: "retry" }],
90
+ [501, { class: "unsupported", action: "fail" }],
91
+ [502, { class: "unavailable", action: "retry" }],
92
+ [503, { class: "unavailable", action: "retry_after" }],
93
+ [504, { class: "timeout", action: "retry" }],
94
+ [507, { class: "capacity", action: "escalate" }],
95
+ [508, { class: "bug", action: "fail" }],
96
+ [509, { class: "quota", action: "escalate" }],
97
+ // Anthropic and several other providers use 529 for "overloaded".
98
+ [529, { class: "unavailable", action: "backoff" }],
99
+ ]);
100
+ /**
101
+ * Exit codes with an agreed meaning. 1 is deliberately absent: it is the
102
+ * generic "it failed" and says nothing about why, so claiming a class for it
103
+ * would be inventing information.
104
+ */
105
+ const EXIT_TABLE = new Map([
106
+ [0, { class: "ok", action: "continue" }],
107
+ [2, { class: "bad_request", action: "fail" }],
108
+ // GNU coreutils `timeout` exits 124 when it had to kill the child.
109
+ [124, { class: "timeout", action: "retry" }],
110
+ [125, { class: "bug", action: "fail" }],
111
+ [126, { class: "permission", action: "fail" }],
112
+ [127, { class: "not_found", action: "fail" }],
113
+ ]);
114
+ /** 128+N, the shell's encoding of "died on signal N". */
115
+ const SIGNAL_TABLE = new Map([
116
+ ["SIGINT", { class: "cancelled", action: "fail" }],
117
+ ["SIGTERM", { class: "cancelled", action: "fail" }],
118
+ ["SIGQUIT", { class: "cancelled", action: "fail" }],
119
+ // A killed process is usually the OOM killer, which is a capacity problem.
120
+ ["SIGKILL", { class: "capacity", action: "escalate" }],
121
+ ["SIGSEGV", { class: "bug", action: "fail" }],
122
+ ["SIGABRT", { class: "bug", action: "fail" }],
123
+ ["SIGBUS", { class: "bug", action: "fail" }],
124
+ ["SIGFPE", { class: "bug", action: "fail" }],
125
+ ["SIGPIPE", { class: "transient", action: "retry" }],
126
+ ["SIGALRM", { class: "timeout", action: "retry" }],
127
+ ["SIGHUP", { class: "transient", action: "retry" }],
128
+ ["SIGXFSZ", { class: "too_large", action: "reduce_input" }],
129
+ ]);
130
+ const EXIT_SIGNALS = new Map([
131
+ [129, "SIGHUP"],
132
+ [130, "SIGINT"],
133
+ [131, "SIGQUIT"],
134
+ [134, "SIGABRT"],
135
+ [136, "SIGFPE"],
136
+ [137, "SIGKILL"],
137
+ [138, "SIGBUS"],
138
+ [139, "SIGSEGV"],
139
+ [141, "SIGPIPE"],
140
+ [142, "SIGALRM"],
141
+ [143, "SIGTERM"],
142
+ [153, "SIGXFSZ"],
143
+ ]);
144
+ const CODE_TABLE = new Map([
145
+ ["ECONNREFUSED", { class: "unavailable", action: "retry" }],
146
+ ["ECONNRESET", { class: "transient", action: "retry" }],
147
+ ["ECONNABORTED", { class: "transient", action: "retry" }],
148
+ ["ETIMEDOUT", { class: "timeout", action: "retry" }],
149
+ ["ESOCKETTIMEDOUT", { class: "timeout", action: "retry" }],
150
+ ["EPIPE", { class: "transient", action: "retry" }],
151
+ ["EHOSTUNREACH", { class: "unavailable", action: "retry" }],
152
+ ["ENETUNREACH", { class: "unavailable", action: "retry" }],
153
+ ["ENETDOWN", { class: "unavailable", action: "retry" }],
154
+ // EAI_AGAIN is a temporary resolver failure; ENOTFOUND is a real NXDOMAIN.
155
+ ["EAI_AGAIN", { class: "transient", action: "retry" }],
156
+ ["ENOTFOUND", { class: "not_found", action: "fail" }],
157
+ ["ENOENT", { class: "not_found", action: "fail" }],
158
+ ["EEXIST", { class: "conflict", action: "escalate" }],
159
+ ["EACCES", { class: "permission", action: "escalate" }],
160
+ ["EPERM", { class: "permission", action: "escalate" }],
161
+ ["EROFS", { class: "permission", action: "escalate" }],
162
+ ["ENOSPC", { class: "capacity", action: "escalate" }],
163
+ ["EDQUOT", { class: "quota", action: "escalate" }],
164
+ ["EMFILE", { class: "capacity", action: "backoff" }],
165
+ ["ENFILE", { class: "capacity", action: "backoff" }],
166
+ ["ENOMEM", { class: "capacity", action: "escalate" }],
167
+ ["EAGAIN", { class: "transient", action: "backoff" }],
168
+ ["EBUSY", { class: "conflict", action: "backoff" }],
169
+ ["EISDIR", { class: "bad_request", action: "fail" }],
170
+ ["ENOTDIR", { class: "bad_request", action: "fail" }],
171
+ ["ENOTEMPTY", { class: "conflict", action: "escalate" }],
172
+ ["EMSGSIZE", { class: "too_large", action: "reduce_input" }],
173
+ ["ELOOP", { class: "bad_request", action: "fail" }],
174
+ ["ENAMETOOLONG", { class: "bad_request", action: "fail" }],
175
+ ["CERT_HAS_EXPIRED", { class: "integrity", action: "fail" }],
176
+ ["DEPTH_ZERO_SELF_SIGNED_CERT", { class: "integrity", action: "fail" }],
177
+ ["SELF_SIGNED_CERT_IN_CHAIN", { class: "integrity", action: "fail" }],
178
+ ["UNABLE_TO_VERIFY_LEAF_SIGNATURE", { class: "integrity", action: "fail" }],
179
+ ["ERR_TLS_CERT_ALTNAME_INVALID", { class: "integrity", action: "fail" }],
180
+ ["insufficient_quota", { class: "quota", action: "escalate" }],
181
+ ["rate_limit_exceeded", { class: "rate_limited", action: "retry_after" }],
182
+ ["context_length_exceeded", { class: "too_large", action: "reduce_input" }],
183
+ ["overloaded_error", { class: "unavailable", action: "backoff" }],
184
+ ["invalid_api_key", { class: "auth", action: "reauth" }],
185
+ ["authentication_error", { class: "auth", action: "reauth" }],
186
+ ["permission_error", { class: "permission", action: "escalate" }],
187
+ ["not_found_error", { class: "not_found", action: "fail" }],
188
+ ["invalid_request_error", { class: "bad_request", action: "fail" }],
189
+ ["api_error", { class: "transient", action: "retry" }],
190
+ ]);
191
+ /**
192
+ * Message signatures, tried in order. Plain lowercased substrings, not
193
+ * regular expressions: these run on attacker-influenced text (a remote
194
+ * server's error body), and a pattern with nested quantifiers there is a
195
+ * denial of service waiting to happen. Order matters — the more specific
196
+ * phrase has to come first.
197
+ */
198
+ const MESSAGE_TABLE = [
199
+ ["too many requests", { class: "rate_limited", action: "retry_after" }],
200
+ ["rate limit", { class: "rate_limited", action: "retry_after" }],
201
+ ["rate_limit", { class: "rate_limited", action: "retry_after" }],
202
+ ["credit balance is too low", { class: "quota", action: "escalate" }],
203
+ ["insufficient funds", { class: "quota", action: "escalate" }],
204
+ ["insufficient_quota", { class: "quota", action: "escalate" }],
205
+ ["billing", { class: "quota", action: "escalate" }],
206
+ ["quota", { class: "quota", action: "escalate" }],
207
+ ["maximum context length", { class: "too_large", action: "reduce_input" }],
208
+ ["context length", { class: "too_large", action: "reduce_input" }],
209
+ ["context window", { class: "too_large", action: "reduce_input" }],
210
+ ["too many tokens", { class: "too_large", action: "reduce_input" }],
211
+ ["request entity too large", { class: "too_large", action: "reduce_input" }],
212
+ ["payload too large", { class: "too_large", action: "reduce_input" }],
213
+ ["javascript heap out of memory", { class: "capacity", action: "escalate" }],
214
+ ["out of memory", { class: "capacity", action: "escalate" }],
215
+ ["no space left on device", { class: "capacity", action: "escalate" }],
216
+ ["cannot allocate memory", { class: "capacity", action: "escalate" }],
217
+ ["invalid api key", { class: "auth", action: "reauth" }],
218
+ ["incorrect api key", { class: "auth", action: "reauth" }],
219
+ ["api key not valid", { class: "auth", action: "reauth" }],
220
+ ["unauthorized", { class: "auth", action: "reauth" }],
221
+ ["authentication failed", { class: "auth", action: "reauth" }],
222
+ ["token expired", { class: "auth", action: "reauth" }],
223
+ ["permission denied", { class: "permission", action: "escalate" }],
224
+ ["access denied", { class: "permission", action: "escalate" }],
225
+ ["forbidden", { class: "permission", action: "escalate" }],
226
+ ["certificate", { class: "integrity", action: "fail" }],
227
+ ["checksum mismatch", { class: "integrity", action: "fail" }],
228
+ ["signature verification failed", { class: "integrity", action: "fail" }],
229
+ ["connection refused", { class: "unavailable", action: "retry" }],
230
+ ["connection reset", { class: "transient", action: "retry" }],
231
+ ["socket hang up", { class: "transient", action: "retry" }],
232
+ ["temporarily unavailable", { class: "unavailable", action: "retry" }],
233
+ ["service unavailable", { class: "unavailable", action: "retry" }],
234
+ ["server is overloaded", { class: "unavailable", action: "backoff" }],
235
+ ["overloaded", { class: "unavailable", action: "backoff" }],
236
+ ["timed out", { class: "timeout", action: "retry" }],
237
+ ["timeout", { class: "timeout", action: "retry" }],
238
+ ["deadline exceeded", { class: "timeout", action: "retry" }],
239
+ ["already exists", { class: "conflict", action: "escalate" }],
240
+ ["conflict", { class: "conflict", action: "escalate" }],
241
+ ["not found", { class: "not_found", action: "fail" }],
242
+ ["no such file or directory", { class: "not_found", action: "fail" }],
243
+ ["command not found", { class: "not_found", action: "fail" }],
244
+ ["cancell", { class: "cancelled", action: "fail" }],
245
+ ["aborted", { class: "cancelled", action: "fail" }],
246
+ ];
247
+ /** Guards the substring scan against a multi-megabyte error body. */
248
+ const MAX_MESSAGE_CHARS = 64_000;
249
+ /**
250
+ * Parse `Retry-After`. Two forms are legal: delta-seconds, and an HTTP-date.
251
+ * The date form needs a reference point to become a duration, so without
252
+ * `nowMs` it is returned as a date and `waitMs` stays null.
253
+ */
254
+ export function parseRetryAfter(raw, nowMs) {
255
+ const text = raw.trim();
256
+ if (text === "")
257
+ return { waitMs: null, retryAt: null };
258
+ if (/^\d+$/.test(text)) {
259
+ const seconds = Number.parseInt(text, 10);
260
+ return { waitMs: seconds * 1000, retryAt: null };
261
+ }
262
+ const parsed = Date.parse(text);
263
+ if (Number.isNaN(parsed))
264
+ return { waitMs: null, retryAt: null };
265
+ const retryAt = new Date(parsed).toISOString();
266
+ if (nowMs === undefined)
267
+ return { waitMs: null, retryAt };
268
+ // A date already in the past means "retry now", not "wait a negative time".
269
+ return { waitMs: Math.max(0, parsed - nowMs), retryAt };
270
+ }
271
+ /**
272
+ * Match a caller rule.
273
+ *
274
+ * `message` and `code` are tested SEPARATELY rather than concatenated. Two
275
+ * reasons, and the second is the important one:
276
+ *
277
+ * - A pattern anchored with `$` means "the end of the message", and against
278
+ * a joined `"<message> <code>"` it could never match.
279
+ * - Because it could never match, the engine has to exhaust every
280
+ * alternative before saying so — which turns a pattern that would have
281
+ * matched immediately into a backtracking search over attacker-supplied
282
+ * text. Joining the fields manufactured the worst case for free.
283
+ */
284
+ function matchCustom(signal, rules, fields) {
285
+ for (const rule of rules) {
286
+ if (rule.status !== undefined && rule.status !== signal.status)
287
+ continue;
288
+ if (rule.exitCode !== undefined && rule.exitCode !== signal.exitCode)
289
+ continue;
290
+ if (rule.contains !== undefined) {
291
+ const needle = rule.contains.toLowerCase();
292
+ if (!fields.some((f) => f.includes(needle)))
293
+ continue;
294
+ }
295
+ if (rule.matches !== undefined) {
296
+ let re;
297
+ try {
298
+ re = new RegExp(rule.matches, "i");
299
+ }
300
+ catch (err) {
301
+ throw new Error(`rule "${rule.id}" has an invalid pattern: ${err.message}`);
302
+ }
303
+ if (!fields.some((f) => re.test(f)))
304
+ continue;
305
+ }
306
+ // A rule with no conditions at all would match everything silently.
307
+ const hasCondition = rule.status !== undefined ||
308
+ rule.exitCode !== undefined ||
309
+ rule.contains !== undefined ||
310
+ rule.matches !== undefined;
311
+ if (!hasCondition)
312
+ throw new Error(`rule "${rule.id}" has no conditions, so it matches every error`);
313
+ return rule;
314
+ }
315
+ return null;
316
+ }
317
+ export function classifyError(signal, options = {}) {
318
+ const message = (signal.message ?? "").slice(0, MAX_MESSAGE_CHARS);
319
+ const fields = [message.toLowerCase(), (signal.code ?? "").toLowerCase()];
320
+ const retry = signal.retryAfter === undefined
321
+ ? { waitMs: null, retryAt: null }
322
+ : parseRetryAfter(signal.retryAfter, options.nowMs);
323
+ let verdict = null;
324
+ let matched = "default";
325
+ let source = "default";
326
+ const custom = matchCustom(signal, options.rules ?? [], fields);
327
+ if (custom) {
328
+ verdict = { class: custom.class, action: custom.action, waitMs: custom.waitMs };
329
+ matched = custom.id;
330
+ source = "custom";
331
+ }
332
+ if (!verdict && signal.status !== undefined) {
333
+ const found = STATUS_TABLE.get(signal.status);
334
+ if (found) {
335
+ verdict = found;
336
+ matched = `status:${signal.status}`;
337
+ source = "status";
338
+ }
339
+ else if (signal.status >= 200 && signal.status < 300) {
340
+ verdict = { class: "ok", action: "continue" };
341
+ matched = "status:2xx";
342
+ source = "status";
343
+ }
344
+ else if (signal.status >= 300 && signal.status < 400) {
345
+ verdict = { class: "redirect", action: "fail" };
346
+ matched = "status:3xx";
347
+ source = "status";
348
+ }
349
+ else if (signal.status >= 500) {
350
+ verdict = { class: "transient", action: "retry" };
351
+ matched = "status:5xx";
352
+ source = "status";
353
+ }
354
+ else if (signal.status >= 400) {
355
+ verdict = { class: "bad_request", action: "fail" };
356
+ matched = "status:4xx";
357
+ source = "status";
358
+ }
359
+ }
360
+ if (!verdict && signal.signal !== undefined) {
361
+ const found = SIGNAL_TABLE.get(signal.signal.toUpperCase());
362
+ if (found) {
363
+ verdict = found;
364
+ matched = `signal:${signal.signal.toUpperCase()}`;
365
+ source = "signal";
366
+ }
367
+ }
368
+ if (!verdict && signal.exitCode !== undefined) {
369
+ const direct = EXIT_TABLE.get(signal.exitCode);
370
+ if (direct) {
371
+ verdict = direct;
372
+ matched = `exit:${signal.exitCode}`;
373
+ source = "exit";
374
+ }
375
+ else {
376
+ const named = EXIT_SIGNALS.get(signal.exitCode);
377
+ const found = named ? SIGNAL_TABLE.get(named) : undefined;
378
+ if (named && found) {
379
+ verdict = found;
380
+ matched = `exit:${signal.exitCode} (${named})`;
381
+ source = "signal";
382
+ }
383
+ }
384
+ }
385
+ if (!verdict && signal.code !== undefined) {
386
+ const found = CODE_TABLE.get(signal.code) ?? CODE_TABLE.get(signal.code.toUpperCase());
387
+ if (found) {
388
+ verdict = found;
389
+ matched = `code:${signal.code}`;
390
+ source = "code";
391
+ }
392
+ }
393
+ if (!verdict && message !== "") {
394
+ const lower = message.toLowerCase();
395
+ for (const [needle, found] of MESSAGE_TABLE) {
396
+ if (lower.includes(needle)) {
397
+ verdict = found;
398
+ matched = `message:${needle}`;
399
+ source = "message";
400
+ break;
401
+ }
402
+ }
403
+ }
404
+ if (!verdict)
405
+ verdict = { class: "unknown", action: "escalate" };
406
+ // A server that told us when to come back outranks a generic "retry".
407
+ let action = verdict.action;
408
+ const waitMs = verdict.waitMs ?? retry.waitMs;
409
+ if (RETRY_BY_DEFAULT.has(verdict.class) && retry.waitMs !== null && action === "retry") {
410
+ action = "retry_after";
411
+ }
412
+ if (action === "retry_after" && waitMs === null && retry.retryAt === null)
413
+ action = "backoff";
414
+ // Attempts run out regardless of how retryable the class is. Without this
415
+ // a loop reading `action` alone retries a 503 forever.
416
+ const exhausted = signal.attempt !== undefined &&
417
+ signal.maxAttempts !== undefined &&
418
+ signal.attempt >= signal.maxAttempts;
419
+ if (exhausted && RETRY_ACTIONS.has(action))
420
+ action = "escalate";
421
+ // `retryable` follows the action, not the class. Deriving it from the class
422
+ // instead would report an OOM kill — class `capacity`, action `escalate` —
423
+ // as retryable, and a caller that reads the boolean rather than the action
424
+ // would re-run the identical command and be killed identically.
425
+ const retryable = RETRY_ACTIONS.has(action);
426
+ return {
427
+ class: verdict.class,
428
+ action,
429
+ retryable,
430
+ waitMs: waitMs ?? null,
431
+ retryAt: retry.retryAt,
432
+ matched,
433
+ source,
434
+ exhausted,
435
+ };
436
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Additive rule scoring with an attributable breakdown.
3
+ *
4
+ * Any "score this thing and decide which band it lands in" job — lead
5
+ * qualification, risk, triage priority — is a table lookup plus addition.
6
+ * The reason to do it here rather than in a model is not only cost: a score
7
+ * a model produced cannot be explained to the person it affects, and cannot
8
+ * be re-run identically next quarter. This returns the contributors, and the
9
+ * version of the rules that produced them.
10
+ */
11
+ import { type Check } from "@crewhaus/tool-schema";
12
+ export type ScoreRule = {
13
+ readonly id: string;
14
+ /** All of these must hold for the points to be awarded. */
15
+ readonly when: ReadonlyArray<Check>;
16
+ readonly points: number;
17
+ /** Human-readable reason, for the note that goes on the record. */
18
+ readonly label?: string;
19
+ };
20
+ export type ScoreBand = {
21
+ readonly name: string;
22
+ /** Inclusive lower bound. The highest band whose min is met wins. */
23
+ readonly min: number;
24
+ };
25
+ export type ScoreModel = {
26
+ readonly version?: string;
27
+ readonly rules: ReadonlyArray<ScoreRule>;
28
+ readonly bands?: ReadonlyArray<ScoreBand>;
29
+ /** Clamp the total into this range after summing. */
30
+ readonly min?: number;
31
+ readonly max?: number;
32
+ };
33
+ export type ScoreResult = {
34
+ readonly score: number;
35
+ /** The sum before clamping, which differs from `score` when it was clamped. */
36
+ readonly rawScore: number;
37
+ readonly band: string | null;
38
+ readonly version: string | null;
39
+ readonly contributors: ReadonlyArray<{
40
+ readonly id: string;
41
+ readonly label: string;
42
+ readonly points: number;
43
+ }>;
44
+ /** Rules that did not fire, with the first reason they did not. */
45
+ readonly missed: ReadonlyArray<{
46
+ readonly id: string;
47
+ readonly reason: string;
48
+ }>;
49
+ };
50
+ export declare function scoreValue(value: unknown, model: ScoreModel): ScoreResult;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Additive rule scoring with an attributable breakdown.
3
+ *
4
+ * Any "score this thing and decide which band it lands in" job — lead
5
+ * qualification, risk, triage priority — is a table lookup plus addition.
6
+ * The reason to do it here rather than in a model is not only cost: a score
7
+ * a model produced cannot be explained to the person it affects, and cannot
8
+ * be re-run identically next quarter. This returns the contributors, and the
9
+ * version of the rules that produced them.
10
+ */
11
+ import { runChecks } from "@crewhaus/tool-schema";
12
+ export function scoreValue(value, model) {
13
+ if (model.rules.length === 0)
14
+ throw new Error("the scoring model has no rules");
15
+ const seen = new Set();
16
+ for (const rule of model.rules) {
17
+ if (rule.when.length === 0) {
18
+ throw new Error(`rule "${rule.id}" has no checks, so it would always award its points`);
19
+ }
20
+ if (seen.has(rule.id))
21
+ throw new Error(`two rules share the id "${rule.id}"`);
22
+ seen.add(rule.id);
23
+ if (!Number.isFinite(rule.points)) {
24
+ throw new Error(`rule "${rule.id}" has non-finite points`);
25
+ }
26
+ }
27
+ if (model.min !== undefined && model.max !== undefined && model.min > model.max) {
28
+ throw new Error(`min (${model.min}) is above max (${model.max})`);
29
+ }
30
+ const contributors = [];
31
+ const missed = [];
32
+ let rawScore = 0;
33
+ for (const rule of model.rules) {
34
+ const report = runChecks(value, rule.when);
35
+ if (report.ok) {
36
+ rawScore += rule.points;
37
+ contributors.push({ id: rule.id, label: rule.label ?? rule.id, points: rule.points });
38
+ }
39
+ else {
40
+ missed.push({ id: rule.id, reason: report.failures[0]?.reason ?? "" });
41
+ }
42
+ }
43
+ let score = rawScore;
44
+ if (model.min !== undefined)
45
+ score = Math.max(model.min, score);
46
+ if (model.max !== undefined)
47
+ score = Math.min(model.max, score);
48
+ let band = null;
49
+ if (model.bands && model.bands.length > 0) {
50
+ // Highest qualifying band wins, whatever order they were declared in, so
51
+ // a table written low-to-high and one written high-to-low agree.
52
+ const sorted = [...model.bands].sort((a, b) => b.min - a.min);
53
+ band = sorted.find((b) => score >= b.min)?.name ?? null;
54
+ }
55
+ return { score, rawScore, band, version: model.version ?? null, contributors, missed };
56
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Plan a declared sequence of steps. Nothing here runs one.
3
+ *
4
+ * A sequence is the other half of {@link ./branch}: a branch answers "which
5
+ * way", a sequence answers "what next, and what is it still waiting on". The
6
+ * answer is a plan — the steps whose prerequisites are met, whose gate time
7
+ * has passed and whose conditions hold, each carrying the parameters the
8
+ * caller should execute it with.
9
+ *
10
+ * It stops there on purpose. Invoking a tool needs the executor, which no
11
+ * tool package has, and a "runner" that quietly returned the steps it would
12
+ * have run is the shape of lie a caller only discovers in production. So
13
+ * this returns the plan and the caller — or the harness, which does have the
14
+ * executor — acts on it.
15
+ *
16
+ * Progress is an argument, not a memory. `completed` and `failed` come in
17
+ * the way {@link ./stall} takes its history, so the same inputs give the
18
+ * same plan in a test, in a replay and in production.
19
+ *
20
+ * Three distinctions carry the weight here, and each one is a different
21
+ * answer a caller has to be able to tell apart:
22
+ *
23
+ * - **waiting** — a prerequisite has not run yet, or the step is not due
24
+ * yet. It can still happen.
25
+ * - **unreachable** — a prerequisite failed or was skipped, so this step can
26
+ * never happen. Reporting that as "waiting" is how a flow hangs forever on
27
+ * a step that died three turns ago.
28
+ * - **skipped** — the step's own conditions did not hold. A settled no.
29
+ */
30
+ import { type Check } from "@crewhaus/tool-schema";
31
+ import type { MatchMode } from "./branch";
32
+ /**
33
+ * Where a flow stands.
34
+ *
35
+ * `finished` and `halted` are kept apart on purpose. Both mean there is
36
+ * nothing left to run, and a caller that reads one boolean for both reports
37
+ * a cadence that died on its second step as a cadence that completed.
38
+ */
39
+ export declare const PLAN_STATES: readonly ["ready", "blocked", "finished", "halted"];
40
+ export type PlanState = (typeof PLAN_STATES)[number];
41
+ export type SequenceStep = {
42
+ readonly id: string;
43
+ /**
44
+ * The step's own gate. Optional, unlike a branch arm's: arms are
45
+ * alternatives, so a condition-less arm swallows the ones after it, while
46
+ * sequence steps are not alternatives and an unconditional step is the
47
+ * ordinary case. An explicitly empty list is still rejected, because it
48
+ * says "gated" and means nothing.
49
+ */
50
+ readonly when?: ReadonlyArray<Check>;
51
+ readonly match?: MatchMode;
52
+ /** Ids that must be completed first. */
53
+ readonly needs?: ReadonlyArray<string>;
54
+ /** Handed back verbatim in the plan. Any JSON value. */
55
+ readonly params?: unknown;
56
+ /** Not due before this instant, as epoch ms. */
57
+ readonly afterMs?: number;
58
+ };
59
+ export type SequenceSpec = {
60
+ /** Echoed into the plan, so a change of flow is attributable. */
61
+ readonly version?: string;
62
+ readonly steps: ReadonlyArray<SequenceStep>;
63
+ };
64
+ export type SequenceState = {
65
+ readonly nowMs: number;
66
+ readonly completed?: ReadonlyArray<string>;
67
+ readonly failed?: ReadonlyArray<string>;
68
+ };
69
+ /** A step the caller should execute now. */
70
+ export type PlannedStep = {
71
+ readonly id: string;
72
+ /** Position in the declared steps. */
73
+ readonly index: number;
74
+ readonly params?: unknown;
75
+ };
76
+ /** A step that is not in the plan, and why. */
77
+ export type HeldStep = {
78
+ readonly id: string;
79
+ readonly reason: string;
80
+ /** Only on a step held by its `after` gate: how long until it is due. */
81
+ readonly dueInMs?: number;
82
+ };
83
+ export type SequencePlan = {
84
+ /** Where the flow stands, as a label a branch can switch on. */
85
+ readonly state: PlanState;
86
+ /** The plan, in declared order. Executing these is the caller's job. */
87
+ readonly ready: ReadonlyArray<PlannedStep>;
88
+ /** `ready[0]`, for the caller that only wants the next one. */
89
+ readonly next: PlannedStep | null;
90
+ readonly waiting: ReadonlyArray<HeldStep>;
91
+ readonly unreachable: ReadonlyArray<HeldStep>;
92
+ readonly skipped: ReadonlyArray<HeldStep>;
93
+ readonly completed: ReadonlyArray<string>;
94
+ readonly failed: ReadonlyArray<string>;
95
+ readonly version: string | null;
96
+ readonly total: number;
97
+ };
98
+ /**
99
+ * Work out what to run next.
100
+ *
101
+ * Structural problems throw rather than being reported, the way a duplicate
102
+ * row id does elsewhere in this package: a cycle, a `needs` naming a step
103
+ * that does not exist, or progress naming a step that does not exist are all
104
+ * bugs in the spec or in the caller's bookkeeping, not answers about this
105
+ * particular record. In particular an unknown id in `completed` — a typo, or
106
+ * a leftover from an older revision of the flow — would silently re-run a
107
+ * step the caller believes is finished.
108
+ */
109
+ export declare function planSequence(value: unknown, spec: SequenceSpec, state: SequenceState): SequencePlan;