@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,244 @@
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 { runChecks } from "@crewhaus/tool-schema";
31
+ /**
32
+ * Where a flow stands.
33
+ *
34
+ * `finished` and `halted` are kept apart on purpose. Both mean there is
35
+ * nothing left to run, and a caller that reads one boolean for both reports
36
+ * a cadence that died on its second step as a cadence that completed.
37
+ */
38
+ export const PLAN_STATES = ["ready", "blocked", "finished", "halted"];
39
+ /**
40
+ * Work out what to run next.
41
+ *
42
+ * Structural problems throw rather than being reported, the way a duplicate
43
+ * row id does elsewhere in this package: a cycle, a `needs` naming a step
44
+ * that does not exist, or progress naming a step that does not exist are all
45
+ * bugs in the spec or in the caller's bookkeeping, not answers about this
46
+ * particular record. In particular an unknown id in `completed` — a typo, or
47
+ * a leftover from an older revision of the flow — would silently re-run a
48
+ * step the caller believes is finished.
49
+ */
50
+ export function planSequence(value, spec, state) {
51
+ const steps = spec.steps;
52
+ if (steps.length === 0)
53
+ throw new Error("the sequence has no steps");
54
+ const byId = new Map();
55
+ for (const step of steps) {
56
+ if (step.when && step.when.length === 0) {
57
+ throw new Error(`step "${step.id}" has an empty condition list — omit "when" for a step that is always due`);
58
+ }
59
+ if (byId.has(step.id))
60
+ throw new Error(`two steps share the id "${step.id}"`);
61
+ byId.set(step.id, step);
62
+ }
63
+ for (const step of steps) {
64
+ for (const need of step.needs ?? []) {
65
+ if (need === step.id)
66
+ throw new Error(`step "${step.id}" needs itself`);
67
+ if (!byId.has(need)) {
68
+ throw new Error(`step "${step.id}" needs "${need}", which is not a step in this sequence`);
69
+ }
70
+ }
71
+ }
72
+ const completed = new Set(state.completed ?? []);
73
+ const failed = new Set(state.failed ?? []);
74
+ for (const [label, ids] of [
75
+ ["completed", completed],
76
+ ["failed", failed],
77
+ ]) {
78
+ for (const id of ids) {
79
+ if (!byId.has(id)) {
80
+ throw new Error(`${label} names "${id}", which is not a step in this sequence`);
81
+ }
82
+ }
83
+ }
84
+ for (const id of completed) {
85
+ if (failed.has(id))
86
+ throw new Error(`step "${id}" is listed as both completed and failed`);
87
+ }
88
+ // Kahn's algorithm, which both orders the walk and finds the cycle. Every
89
+ // step's prerequisites are decided before the step itself, so a chain of
90
+ // unreachability propagates in one pass instead of needing a fixpoint.
91
+ const order = topologicalOrder(steps, byId);
92
+ const states = new Map();
93
+ const reasons = new Map();
94
+ for (const step of order) {
95
+ if (failed.has(step.id)) {
96
+ states.set(step.id, "failed");
97
+ continue;
98
+ }
99
+ if (completed.has(step.id)) {
100
+ states.set(step.id, "done");
101
+ continue;
102
+ }
103
+ const dead = (step.needs ?? []).find((need) => {
104
+ const s = states.get(need);
105
+ return s === "failed" || s === "skipped" || s === "unreachable";
106
+ });
107
+ if (dead !== undefined) {
108
+ states.set(step.id, "unreachable");
109
+ reasons.set(step.id, {
110
+ id: step.id,
111
+ reason: `needs "${dead}", which is ${states.get(dead)}`,
112
+ });
113
+ continue;
114
+ }
115
+ const pending = (step.needs ?? []).find((need) => states.get(need) !== "done");
116
+ if (pending !== undefined) {
117
+ states.set(step.id, "waiting");
118
+ reasons.set(step.id, { id: step.id, reason: `needs "${pending}", which has not run` });
119
+ continue;
120
+ }
121
+ // The `after` gate is read before the step's own conditions, and the
122
+ // conditions are read only once the prerequisites are done. Both orders
123
+ // are deliberate: a gate usually reads something an earlier step
124
+ // produced, so evaluating it out of turn would report a settled `skipped`
125
+ // about a context that does not exist yet.
126
+ if (step.afterMs !== undefined && step.afterMs > state.nowMs) {
127
+ states.set(step.id, "waiting");
128
+ reasons.set(step.id, {
129
+ id: step.id,
130
+ reason: `not due until ${new Date(step.afterMs).toISOString()}`,
131
+ dueInMs: step.afterMs - state.nowMs,
132
+ });
133
+ continue;
134
+ }
135
+ // The shared evaluator reports a check it could not evaluate — a regex
136
+ // that will not compile, a malformed path — as a check that did not hold,
137
+ // with the reason as its text and no flag to tell the two apart. That is
138
+ // `Branch` and `RuleScore`'s behaviour too, and inventing a different one
139
+ // here would mean two answers to the same question. It bites harder in a
140
+ // sequence, because the skip cascades into `unreachable` dependents, so
141
+ // the reason travels with every one of them.
142
+ if (step.when && step.when.length > 0) {
143
+ const report = runChecks(value, step.when);
144
+ const holds = (step.match ?? "all") === "all" ? report.ok : report.passed > 0;
145
+ if (!holds) {
146
+ states.set(step.id, "skipped");
147
+ reasons.set(step.id, {
148
+ id: step.id,
149
+ reason: report.failures[0]?.reason ?? "no condition held",
150
+ });
151
+ continue;
152
+ }
153
+ }
154
+ states.set(step.id, "ready");
155
+ }
156
+ // Emitted in declared order, not in the topological order the walk used:
157
+ // the caller wrote the sequence down in the order they think about it, and
158
+ // a plan that came back reshuffled would read as a reordering.
159
+ const ready = [];
160
+ const waiting = [];
161
+ const unreachable = [];
162
+ const skipped = [];
163
+ const doneIds = [];
164
+ const failedIds = [];
165
+ for (const [index, step] of steps.entries()) {
166
+ const held = reasons.get(step.id) ?? { id: step.id, reason: "" };
167
+ switch (states.get(step.id)) {
168
+ case "ready":
169
+ ready.push(step.params === undefined
170
+ ? { id: step.id, index }
171
+ : { id: step.id, index, params: step.params });
172
+ break;
173
+ case "waiting":
174
+ waiting.push(held);
175
+ break;
176
+ case "unreachable":
177
+ unreachable.push(held);
178
+ break;
179
+ case "skipped":
180
+ skipped.push(held);
181
+ break;
182
+ case "done":
183
+ doneIds.push(step.id);
184
+ break;
185
+ case "failed":
186
+ failedIds.push(step.id);
187
+ break;
188
+ }
189
+ }
190
+ const planState = ready.length > 0
191
+ ? "ready"
192
+ : waiting.length > 0
193
+ ? "blocked"
194
+ : failedIds.length > 0 || unreachable.length > 0
195
+ ? "halted"
196
+ : "finished";
197
+ return {
198
+ state: planState,
199
+ ready,
200
+ next: ready[0] ?? null,
201
+ waiting,
202
+ unreachable,
203
+ skipped,
204
+ completed: doneIds,
205
+ failed: failedIds,
206
+ version: spec.version ?? null,
207
+ total: steps.length,
208
+ };
209
+ }
210
+ /** Steps ordered so that every step follows the steps it needs. */
211
+ function topologicalOrder(steps, byId) {
212
+ const remaining = new Map();
213
+ const dependents = new Map();
214
+ for (const step of steps) {
215
+ const needs = new Set(step.needs ?? []);
216
+ remaining.set(step.id, needs.size);
217
+ for (const need of needs) {
218
+ const list = dependents.get(need);
219
+ if (list)
220
+ list.push(step.id);
221
+ else
222
+ dependents.set(need, [step.id]);
223
+ }
224
+ }
225
+ // Seeded in declared order so the walk is stable, though only the
226
+ // dependency order it guarantees is relied on downstream.
227
+ const queue = steps.filter((s) => remaining.get(s.id) === 0).map((s) => s.id);
228
+ const ordered = [];
229
+ for (let head = 0; head < queue.length; head++) {
230
+ const id = queue[head];
231
+ ordered.push(byId.get(id));
232
+ for (const dependent of dependents.get(id) ?? []) {
233
+ const left = remaining.get(dependent) - 1;
234
+ remaining.set(dependent, left);
235
+ if (left === 0)
236
+ queue.push(dependent);
237
+ }
238
+ }
239
+ if (ordered.length !== steps.length) {
240
+ const stuck = steps.filter((s) => !ordered.includes(s)).map((s) => `"${s.id}"`);
241
+ throw new Error(`steps ${stuck.join(", ")} depend on each other in a cycle`);
242
+ }
243
+ return ordered;
244
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Decide whether a loop is still making progress.
3
+ *
4
+ * A fix-test-fail loop that has stopped moving looks, from inside one turn,
5
+ * exactly like a loop that is about to succeed. The difference is only
6
+ * visible across turns, in whether the signals change — the failing-test
7
+ * fingerprint, the workspace diff hash, the last error, the tool call being
8
+ * made. This takes that history and answers the question.
9
+ *
10
+ * The history is an input. Nothing here persists anything, so the same
11
+ * history always gives the same verdict.
12
+ */
13
+ export declare const STALL_REASONS: readonly ["progressing", "unchanged", "oscillating", "insufficient"];
14
+ export type StallReason = (typeof STALL_REASONS)[number];
15
+ /** One turn's observation: named signals, each an opaque fingerprint. */
16
+ export type Snapshot = Readonly<Record<string, string>>;
17
+ export type StallOptions = {
18
+ /** Identical snapshots needed in a row before calling it stalled. */
19
+ readonly window?: number;
20
+ /**
21
+ * Longest repeating cycle to look for. A loop that alternates between two
22
+ * states is not "unchanged", but it is just as stuck.
23
+ */
24
+ readonly maxCycle?: number;
25
+ /** Only these signals are considered, when given. */
26
+ readonly signals?: ReadonlyArray<string>;
27
+ };
28
+ export type StallReport = {
29
+ readonly stalled: boolean;
30
+ readonly reason: StallReason;
31
+ /** Consecutive identical snapshots at the end of the history. */
32
+ readonly repeats: number;
33
+ /** Length of the detected cycle; 1 means "unchanged". Null when progressing. */
34
+ readonly cycleLength: number | null;
35
+ /** Signals that changed between the last two snapshots. */
36
+ readonly changed: ReadonlyArray<string>;
37
+ /** Signals that have not changed anywhere in the history. */
38
+ readonly frozen: ReadonlyArray<string>;
39
+ readonly observed: number;
40
+ };
41
+ export declare function detectStall(history: ReadonlyArray<Snapshot>, options?: StallOptions): StallReport;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Decide whether a loop is still making progress.
3
+ *
4
+ * A fix-test-fail loop that has stopped moving looks, from inside one turn,
5
+ * exactly like a loop that is about to succeed. The difference is only
6
+ * visible across turns, in whether the signals change — the failing-test
7
+ * fingerprint, the workspace diff hash, the last error, the tool call being
8
+ * made. This takes that history and answers the question.
9
+ *
10
+ * The history is an input. Nothing here persists anything, so the same
11
+ * history always gives the same verdict.
12
+ */
13
+ export const STALL_REASONS = ["progressing", "unchanged", "oscillating", "insufficient"];
14
+ const DEFAULT_WINDOW = 3;
15
+ const DEFAULT_MAX_CYCLE = 3;
16
+ /**
17
+ * A snapshot reduced to a comparable string.
18
+ *
19
+ * Keys are sorted so that two snapshots with the same content compare equal
20
+ * whatever order they were built in, and the pairs are JSON rather than
21
+ * joined with a separator: a fingerprint is opaque caller-supplied text, and
22
+ * any separator could occur inside one, making two different snapshots
23
+ * collide.
24
+ */
25
+ function fingerprint(snapshot, only) {
26
+ const keys = (only ?? Object.keys(snapshot)).slice().sort();
27
+ return JSON.stringify(keys.map((k) => [k, snapshot[k] ?? ""]));
28
+ }
29
+ export function detectStall(history, options = {}) {
30
+ const window = options.window ?? DEFAULT_WINDOW;
31
+ const maxCycle = options.maxCycle ?? DEFAULT_MAX_CYCLE;
32
+ if (window < 2)
33
+ throw new Error("window must be at least 2 — one snapshot cannot show a trend");
34
+ if (maxCycle < 1)
35
+ throw new Error("maxCycle must be at least 1");
36
+ const prints = history.map((s) => fingerprint(s, options.signals));
37
+ const observed = prints.length;
38
+ const changed = [];
39
+ if (observed >= 2) {
40
+ const a = history[observed - 2];
41
+ const b = history[observed - 1];
42
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
43
+ for (const key of [...keys].sort()) {
44
+ if (options.signals && !options.signals.includes(key))
45
+ continue;
46
+ if (a[key] !== b[key])
47
+ changed.push(key);
48
+ }
49
+ }
50
+ const frozen = [];
51
+ if (observed >= 2) {
52
+ const keys = new Set(history.flatMap((s) => Object.keys(s)));
53
+ for (const key of [...keys].sort()) {
54
+ if (options.signals && !options.signals.includes(key))
55
+ continue;
56
+ const first = history[0]?.[key];
57
+ if (history.every((s) => s[key] === first))
58
+ frozen.push(key);
59
+ }
60
+ }
61
+ // Not enough history to say anything. Reporting "progressing" here would be
62
+ // a claim the data does not support, so it gets its own reason.
63
+ if (observed < window) {
64
+ return {
65
+ stalled: false,
66
+ reason: "insufficient",
67
+ repeats: 0,
68
+ cycleLength: null,
69
+ changed,
70
+ frozen,
71
+ observed,
72
+ };
73
+ }
74
+ let repeats = 1;
75
+ for (let i = observed - 1; i > 0; i--) {
76
+ if (prints[i] === prints[i - 1])
77
+ repeats += 1;
78
+ else
79
+ break;
80
+ }
81
+ if (repeats >= window) {
82
+ return {
83
+ stalled: true,
84
+ reason: "unchanged",
85
+ repeats,
86
+ cycleLength: 1,
87
+ changed,
88
+ frozen,
89
+ observed,
90
+ };
91
+ }
92
+ // Oscillation: the tail repeats a cycle of length k, at least twice, and
93
+ // the cycle itself is not constant (that is "unchanged", already handled).
94
+ for (let k = 2; k <= maxCycle; k++) {
95
+ const needed = k * 2;
96
+ if (observed < needed || needed < window)
97
+ continue;
98
+ const tail = prints.slice(observed - needed);
99
+ const first = tail.slice(0, k);
100
+ const second = tail.slice(k);
101
+ const cycles = first.every((p, i) => p === second[i]);
102
+ const varied = new Set(first).size > 1;
103
+ if (cycles && varied) {
104
+ return {
105
+ stalled: true,
106
+ reason: "oscillating",
107
+ repeats,
108
+ cycleLength: k,
109
+ changed,
110
+ frozen,
111
+ observed,
112
+ };
113
+ }
114
+ }
115
+ return {
116
+ stalled: false,
117
+ reason: "progressing",
118
+ repeats,
119
+ cycleLength: null,
120
+ changed,
121
+ frozen,
122
+ observed,
123
+ };
124
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@crewhaus/tool-flow",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Deterministic control-flow tools: branching, decision tables, error classification, deadlines, consensus, stall detection, rule scoring, record routing and sequence planning",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/errors": "0.7.0",
19
+ "@crewhaus/tool-builder": "0.7.0",
20
+ "@crewhaus/tool-catalog": "0.7.0",
21
+ "@crewhaus/tool-schema": "0.7.0",
22
+ "zod": "^3.23.8"
23
+ },
24
+ "devDependencies": {
25
+ "@crewhaus/tool-executor": "workspace:*"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "author": {
29
+ "name": "Max Meier",
30
+ "email": "max@crewhaus.ai",
31
+ "url": "https://crewhaus.ai"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/crewhaus/factory.git",
36
+ "directory": "packages/tool-flow"
37
+ },
38
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-flow#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/crewhaus/factory/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
46
+ }