oh-my-workflow 0.2.1 → 0.4.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 +190 -105
- package/conformance/budget-loop.ts +16 -0
- package/conformance/fanout.ts +20 -0
- package/conformance/pipeline.ts +21 -0
- package/conformance/schema-gate.ts +21 -0
- package/conformance/strict-throws.ts +13 -0
- package/docs/launch/show-hn.md +41 -0
- package/docs/site/index.html +540 -0
- package/docs/site/robots.txt +2 -0
- package/examples/deep-research/workflow.ts +11 -11
- package/package.json +11 -3
- package/scripts/build-docs.ts +10 -0
- package/scripts/check-docs.ts +58 -0
- package/skill/SKILL.md +230 -133
- package/src/adapters/claude.ts +31 -5
- package/src/adapters/codex.ts +5 -3
- package/src/adapters/exec.ts +103 -0
- package/src/adapters/fake.ts +4 -4
- package/src/adapters/hermes.ts +24 -0
- package/src/adapters/types.ts +33 -3
- package/src/cli/codemod.ts +99 -0
- package/src/cli/omw.ts +7 -2
- package/src/cli/run.ts +222 -13
- package/src/cli/skill.ts +32 -10
- package/src/runtime.ts +171 -11
- package/src/worktree.ts +72 -0
- package/vercel.json +5 -0
package/src/runtime.ts
CHANGED
|
@@ -11,6 +11,18 @@ import type { Journal } from "./journal";
|
|
|
11
11
|
import { promptHash, optsHash } from "./journal";
|
|
12
12
|
import type { ResumeIndex } from "./resume";
|
|
13
13
|
import { schemaGate, makeValidator, type GateCall, type GateFeedback } from "./schema-gate";
|
|
14
|
+
import { withWorktree as defaultWithWorktree } from "./worktree";
|
|
15
|
+
|
|
16
|
+
/** Optional `export const meta` a workflow can declare to describe itself and
|
|
17
|
+
* its phases. Mirrors native dynamic-workflow's meta block: a pure literal the
|
|
18
|
+
* loader reads for naming, phase titles, and per-phase/default model hints. */
|
|
19
|
+
export type WorkflowMeta = {
|
|
20
|
+
name?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
whenToUse?: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
phases?: Array<{ title: string; model?: string; detail?: string }>;
|
|
25
|
+
};
|
|
14
26
|
|
|
15
27
|
export type AgentOpts = {
|
|
16
28
|
label?: string;
|
|
@@ -20,6 +32,16 @@ export type AgentOpts = {
|
|
|
20
32
|
cwd?: string;
|
|
21
33
|
timeoutMs?: number;
|
|
22
34
|
maxRetries?: number;
|
|
35
|
+
/** Inherit the host's MCP servers in this node (default false → isolated, fast). */
|
|
36
|
+
inheritMcp?: boolean;
|
|
37
|
+
/** Reasoning-effort hint for this node (adapter maps it where supported). */
|
|
38
|
+
effort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
39
|
+
/** Cross-vendor node profile (named agent persona) for this node. */
|
|
40
|
+
agentType?: string;
|
|
41
|
+
/** Run this node in a fresh ephemeral git worktree (cwd = the worktree), so
|
|
42
|
+
* parallel file-mutating nodes don't clobber each other. Best-effort: a
|
|
43
|
+
* non-git cwd runs in place with a warning. */
|
|
44
|
+
isolation?: "worktree";
|
|
23
45
|
};
|
|
24
46
|
|
|
25
47
|
// `prev`/`item` are intentionally `any`: orchestration scripts are plain JS the
|
|
@@ -27,12 +49,22 @@ export type AgentOpts = {
|
|
|
27
49
|
// without fighting the type system. The runtime treats every value opaquely.
|
|
28
50
|
export type Stage = (prev: any, item: any, index: number) => unknown | Promise<unknown>;
|
|
29
51
|
|
|
52
|
+
/** Shared, mutable token-spend accumulator. Lives outside makeRuntime so a
|
|
53
|
+
* parent and any nested workflow() child can point at the SAME counter — the
|
|
54
|
+
* token pool is shared across the whole run, not per-runtime. */
|
|
55
|
+
export type BudgetState = { spent: number };
|
|
56
|
+
|
|
30
57
|
export type Runtime = {
|
|
31
58
|
agent(prompt: string, opts?: AgentOpts): Promise<unknown | null>;
|
|
32
59
|
pipeline(items: unknown[], ...stages: Stage[]): Promise<unknown[]>;
|
|
33
60
|
parallel(thunks: Array<() => Promise<unknown>>): Promise<unknown[]>;
|
|
61
|
+
workflow(ref: string | { scriptPath: string }, args?: unknown): Promise<unknown>;
|
|
34
62
|
phase(title: string): void;
|
|
35
63
|
log(msg: string): void;
|
|
64
|
+
/** Token budget view. `total` is the ceiling (null = unbounded); `spent()`
|
|
65
|
+
* reads the shared accumulator; `remaining()` is `total - spent` (Infinity
|
|
66
|
+
* when unbounded). The ceiling is enforced in agent() (BudgetExceededError). */
|
|
67
|
+
budget: { total: number | null; spent(): number; remaining(): number };
|
|
36
68
|
};
|
|
37
69
|
|
|
38
70
|
/** Bounded-concurrency gate: at most `max` bodies run at once; the rest queue.
|
|
@@ -61,12 +93,57 @@ export function makeLimiter(max: number) {
|
|
|
61
93
|
|
|
62
94
|
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
63
95
|
|
|
96
|
+
/** The semantic subset of a node's options — everything that changes WHAT the
|
|
97
|
+
* node computes, and nothing cosmetic. The resume key hashes only this, so a
|
|
98
|
+
* display-only change (label/phase) or a retry-policy tweak (timeoutMs/
|
|
99
|
+
* maxRetries) re-uses the cached result instead of needlessly re-running. The
|
|
100
|
+
* resolved model (after the opts>phase>meta chain) is passed in so a meta/phase
|
|
101
|
+
* model change still busts the cache even when opts.model is unset. */
|
|
102
|
+
function pickSemantic(opts: AgentOpts, model: string | undefined) {
|
|
103
|
+
return {
|
|
104
|
+
model,
|
|
105
|
+
schema: opts.schema,
|
|
106
|
+
effort: opts.effort,
|
|
107
|
+
isolation: opts.isolation,
|
|
108
|
+
agentType: opts.agentType,
|
|
109
|
+
cwd: opts.cwd,
|
|
110
|
+
inheritMcp: opts.inheritMcp,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The ONE documented exception to the null-contract: when a token budget is set
|
|
115
|
+
* and already exhausted, agent() throws this instead of returning null, so a
|
|
116
|
+
* budget-bounded loop terminates instead of silently spinning out null nodes.
|
|
117
|
+
* It is thrown OUTSIDE the per-node try, so it propagates; a throw that lands
|
|
118
|
+
* inside parallel()/pipeline() is still swallowed to null (matches native). */
|
|
119
|
+
export class BudgetExceededError extends Error {
|
|
120
|
+
constructor(message: string) {
|
|
121
|
+
super(message);
|
|
122
|
+
this.name = "BudgetExceededError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Cap the echoed prior output so a huge malformed dump can't blow the fresh prompt.
|
|
127
|
+
const RETRY_RAWTEXT_CAP = 4000;
|
|
128
|
+
|
|
64
129
|
function retryPrompt(original: string, feedback: GateFeedback, fresh: boolean): string {
|
|
65
130
|
const note =
|
|
66
131
|
"Your previous output failed validation:\n" +
|
|
67
132
|
feedback.errors.map((e) => `- ${e}`).join("\n") +
|
|
68
133
|
"\nReturn ONLY corrected JSON, no prose.";
|
|
69
|
-
|
|
134
|
+
// In-session followUp (fresh=false): the prior attempt is still in the live
|
|
135
|
+
// transcript, so the errors alone are enough. Fresh invoke (fresh=true): a
|
|
136
|
+
// brand-new subprocess has NO memory of what it produced, so hand its own
|
|
137
|
+
// non-conforming output back (capped) to repair against — otherwise it repairs
|
|
138
|
+
// blind and tends to regress on a different field (the B6 whack-a-mole).
|
|
139
|
+
if (!fresh) return note;
|
|
140
|
+
const prior = feedback.rawText.trim();
|
|
141
|
+
const echo = prior
|
|
142
|
+
? "\nYour previous output (fix THIS, do not start over):\n```\n" +
|
|
143
|
+
(prior.length > RETRY_RAWTEXT_CAP ? prior.slice(0, RETRY_RAWTEXT_CAP) + "\n…(truncated)" : prior) +
|
|
144
|
+
"\n```\n"
|
|
145
|
+
: "";
|
|
146
|
+
return `${original}${echo}\n${note}`;
|
|
70
147
|
}
|
|
71
148
|
|
|
72
149
|
export function makeRuntime(deps: {
|
|
@@ -78,17 +155,45 @@ export function makeRuntime(deps: {
|
|
|
78
155
|
* the longest-unchanged-prefix resume model. A miss (incl. a prior failure)
|
|
79
156
|
* runs live, so resume only re-executes failed/changed nodes. */
|
|
80
157
|
resume?: ResumeIndex;
|
|
158
|
+
/** Token ceiling for the run (null/undefined = unbounded). */
|
|
159
|
+
budget?: number | null;
|
|
160
|
+
/** Shared spend accumulator. When omitted, a local one is created; a nested
|
|
161
|
+
* workflow() passes the parent's so the pool is shared across the run. */
|
|
162
|
+
budgetState?: BudgetState;
|
|
163
|
+
/** The workflow's meta, used to resolve the effective model per node along the
|
|
164
|
+
* `opts.model > phase model > meta.model` chain. */
|
|
165
|
+
meta?: WorkflowMeta;
|
|
166
|
+
/** Injected for isolation:'worktree'; defaults to the real git-backed helper.
|
|
167
|
+
* Overridable so the runtime is testable without a git subprocess. */
|
|
168
|
+
withWorktree?: typeof defaultWithWorktree;
|
|
81
169
|
}): Runtime {
|
|
82
170
|
const { adapter, journal, resume } = deps;
|
|
171
|
+
const withWorktree = deps.withWorktree ?? defaultWithWorktree;
|
|
83
172
|
const limit = makeLimiter(deps.concurrency ?? 4);
|
|
84
173
|
let callCounter = 0;
|
|
85
174
|
let currentPhase: string | undefined;
|
|
175
|
+
const budgetTotal = deps.budget ?? null;
|
|
176
|
+
const budgetState: BudgetState = deps.budgetState ?? { spent: 0 };
|
|
177
|
+
|
|
178
|
+
// Effective model along the precedence chain opts > phase > meta default.
|
|
179
|
+
// Resolved per node so a phase or meta default applies without the script
|
|
180
|
+
// repeating `model` on every agent() call.
|
|
181
|
+
const resolveModel = (opts: AgentOpts, phase: string | undefined): string | undefined => {
|
|
182
|
+
if (opts.model !== undefined) return opts.model;
|
|
183
|
+
// `?.` guards null/undefined but NOT a wrong type — an author typo like
|
|
184
|
+
// `phases: "scan"` would make `.find` throw. Array.isArray closes that gap so
|
|
185
|
+
// a malformed meta degrades to the default model instead of killing the run.
|
|
186
|
+
const phases = deps.meta?.phases;
|
|
187
|
+
const phaseModel = phase && Array.isArray(phases) ? phases.find((p) => p.title === phase)?.model : undefined;
|
|
188
|
+
return phaseModel ?? deps.meta?.model;
|
|
189
|
+
};
|
|
86
190
|
|
|
87
191
|
async function agent(prompt: string, opts: AgentOpts = {}): Promise<unknown | null> {
|
|
88
192
|
const call = ++callCounter;
|
|
89
193
|
const phase = opts.phase ?? currentPhase;
|
|
194
|
+
const model = resolveModel(opts, phase);
|
|
90
195
|
const pHash = promptHash(prompt);
|
|
91
|
-
const oHash = optsHash(opts);
|
|
196
|
+
const oHash = optsHash(pickSemantic(opts, model));
|
|
92
197
|
journal.agentStart({
|
|
93
198
|
call,
|
|
94
199
|
label: opts.label,
|
|
@@ -108,23 +213,48 @@ export function makeRuntime(deps: {
|
|
|
108
213
|
}
|
|
109
214
|
}
|
|
110
215
|
|
|
216
|
+
// Budget ceiling: checked AFTER the resume short-circuit (a cached hit costs
|
|
217
|
+
// nothing) and OUTSIDE limit()'s try, so it propagates as the one sanctioned
|
|
218
|
+
// null-contract exception rather than being swallowed to null.
|
|
219
|
+
if (budgetTotal != null && budgetState.spent >= budgetTotal) {
|
|
220
|
+
throw new BudgetExceededError(`budget exhausted: ${budgetState.spent}/${budgetTotal} tokens`);
|
|
221
|
+
}
|
|
222
|
+
|
|
111
223
|
return limit(async () => {
|
|
224
|
+
// The node body, parameterized by the effective working directory so an
|
|
225
|
+
// isolation:'worktree' node runs the SAME logic with cwd = the worktree.
|
|
226
|
+
const body = async (effCwd: string | undefined): Promise<unknown | null> => {
|
|
112
227
|
let durationMs = 0;
|
|
113
228
|
const account = (r: AgentResult) => {
|
|
114
229
|
durationMs += r.ok ? r.meta.durationMs : (r.meta?.durationMs ?? 0);
|
|
230
|
+
// Count tokens whether the node succeeded or failed: a failed node that
|
|
231
|
+
// still reported `usage` (error/refusal envelope) consumed real budget,
|
|
232
|
+
// so a loop on a failing node trips the ceiling instead of spinning.
|
|
233
|
+
// Guard the value: a buggy/custom adapter (the pluggable boundary) could
|
|
234
|
+
// hand back NaN, a negative, or a non-number — any of which would corrupt
|
|
235
|
+
// `spent` and silently disable the ceiling. Coerce junk to 0.
|
|
236
|
+
const tokens = r.meta?.outputTokens;
|
|
237
|
+
budgetState.spent += typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
|
|
115
238
|
};
|
|
239
|
+
// A fresh node invocation carrying this call's options. Built in one place
|
|
240
|
+
// so the next InvokeRequest field is threaded once, not per call site.
|
|
241
|
+
const invokeFresh = (p: string) =>
|
|
242
|
+
adapter.invoke({
|
|
243
|
+
prompt: p,
|
|
244
|
+
model,
|
|
245
|
+
cwd: effCwd,
|
|
246
|
+
timeoutMs: opts.timeoutMs,
|
|
247
|
+
inheritMcp: opts.inheritMcp,
|
|
248
|
+
effort: opts.effort,
|
|
249
|
+
agentType: opts.agentType,
|
|
250
|
+
});
|
|
116
251
|
|
|
117
252
|
try {
|
|
118
253
|
// No schema: one shot, raw text out (or null).
|
|
119
254
|
if (!opts.schema) {
|
|
120
255
|
let r: AgentResult;
|
|
121
256
|
try {
|
|
122
|
-
r = await
|
|
123
|
-
prompt,
|
|
124
|
-
model: opts.model,
|
|
125
|
-
cwd: opts.cwd,
|
|
126
|
-
timeoutMs: opts.timeoutMs,
|
|
127
|
-
});
|
|
257
|
+
r = await invokeFresh(prompt);
|
|
128
258
|
} catch (e) {
|
|
129
259
|
// A throw at the adapter boundary IS an adapter failure.
|
|
130
260
|
journal.agentEnd({ call, ok: false, kind: "spawn_failure", stderr: errMsg(e), durationMs });
|
|
@@ -146,10 +276,24 @@ export function makeRuntime(deps: {
|
|
|
146
276
|
const gateCall: GateCall = async (_n, feedback) => {
|
|
147
277
|
let r: AgentResult;
|
|
148
278
|
if (feedback && lastSessionId && adapter.followUp) {
|
|
149
|
-
|
|
279
|
+
// Resume in the original cwd and with the same MCP choice, so the
|
|
280
|
+
// repair turn runs in the same environment as the turn it continues.
|
|
281
|
+
r = await adapter.followUp(lastSessionId, retryPrompt(prompt, feedback, false), {
|
|
282
|
+
cwd: effCwd,
|
|
283
|
+
inheritMcp: opts.inheritMcp,
|
|
284
|
+
timeoutMs: opts.timeoutMs,
|
|
285
|
+
});
|
|
286
|
+
// Resume can fail even when the format hiccup was recoverable (e.g. a
|
|
287
|
+
// killed/expired session). Don't let a broken resume be terminal —
|
|
288
|
+
// fall back to a fresh invoke with the error appended (the contract
|
|
289
|
+
// AgentPort documents for the no-followUp case). Account the failed
|
|
290
|
+
// resume too: it spawned a real subprocess, so its time is real cost.
|
|
291
|
+
if (!r.ok) {
|
|
292
|
+
account(r);
|
|
293
|
+
r = await invokeFresh(retryPrompt(prompt, feedback, true));
|
|
294
|
+
}
|
|
150
295
|
} else {
|
|
151
|
-
|
|
152
|
-
r = await adapter.invoke({ prompt: p, model: opts.model, cwd: opts.cwd, timeoutMs: opts.timeoutMs });
|
|
296
|
+
r = await invokeFresh(feedback ? retryPrompt(prompt, feedback, true) : prompt);
|
|
153
297
|
}
|
|
154
298
|
account(r);
|
|
155
299
|
if (r.ok && r.meta.sessionId) lastSessionId = r.meta.sessionId;
|
|
@@ -185,6 +329,14 @@ export function makeRuntime(deps: {
|
|
|
185
329
|
journal.agentEnd({ call, ok: false, kind: "internal_error", error: errMsg(e), durationMs });
|
|
186
330
|
return null;
|
|
187
331
|
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// isolation:'worktree' gives the node its own ephemeral checkout as cwd;
|
|
335
|
+
// otherwise it runs in the caller-provided cwd (or the process cwd).
|
|
336
|
+
if (opts.isolation === "worktree") {
|
|
337
|
+
return withWorktree(opts.cwd ?? process.cwd(), (wt) => body(wt));
|
|
338
|
+
}
|
|
339
|
+
return body(opts.cwd);
|
|
188
340
|
});
|
|
189
341
|
}
|
|
190
342
|
|
|
@@ -226,10 +378,18 @@ export function makeRuntime(deps: {
|
|
|
226
378
|
agent,
|
|
227
379
|
parallel,
|
|
228
380
|
pipeline,
|
|
381
|
+
workflow: async () => {
|
|
382
|
+
throw new Error("workflow() hook is only available through runWorkflow");
|
|
383
|
+
},
|
|
229
384
|
phase: (title: string) => {
|
|
230
385
|
currentPhase = title;
|
|
231
386
|
journal.phase(title);
|
|
232
387
|
},
|
|
233
388
|
log: (msg: string) => journal.log(msg),
|
|
389
|
+
budget: {
|
|
390
|
+
total: budgetTotal,
|
|
391
|
+
spent: () => budgetState.spent,
|
|
392
|
+
remaining: () => (budgetTotal == null ? Infinity : budgetTotal - budgetState.spent),
|
|
393
|
+
},
|
|
234
394
|
};
|
|
235
395
|
}
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Ephemeral git worktree per node, for `agent(prompt, { isolation: 'worktree' })`.
|
|
2
|
+
// When several nodes mutate files in parallel they would clobber each other in a
|
|
3
|
+
// shared checkout; giving each its own `git worktree` isolates them. The worktree
|
|
4
|
+
// is auto-removed when the node left it unchanged, and LEFT IN PLACE (with a warn)
|
|
5
|
+
// when it has changes, so a caller can inspect/merge them. A non-git cwd has no
|
|
6
|
+
// worktree to make — we run in place and warn rather than fail (honest-scope:
|
|
7
|
+
// isolation is best-effort, the null-contract still holds).
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export type GitSpawnResult = { code: number; stdout: string; stderr: string };
|
|
12
|
+
export type GitSpawn = (args: string[], cwd: string) => Promise<GitSpawnResult>;
|
|
13
|
+
|
|
14
|
+
export type WorktreeDeps = {
|
|
15
|
+
/** Injected so the unit under test drives git without a subprocess; defaults
|
|
16
|
+
* to a real `git` over Bun.spawn. */
|
|
17
|
+
spawn?: GitSpawn;
|
|
18
|
+
warn?: (msg: string) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function defaultGitSpawn(): GitSpawn {
|
|
22
|
+
return async (args, cwd) => {
|
|
23
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
24
|
+
const [stdout, stderr] = await Promise.all([
|
|
25
|
+
new Response(proc.stdout).text(),
|
|
26
|
+
new Response(proc.stderr).text(),
|
|
27
|
+
]);
|
|
28
|
+
const code = await proc.exited;
|
|
29
|
+
return { code, stdout, stderr };
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Per-process counter so concurrent worktrees get distinct dirs WITHOUT Date.now
|
|
34
|
+
// or Math.random (kept deterministic-friendly, mirroring the rest of the engine).
|
|
35
|
+
let wtCounter = 0;
|
|
36
|
+
|
|
37
|
+
/** Run `fn` with an ephemeral detached git worktree as its working directory,
|
|
38
|
+
* then clean up. Returns whatever `fn` returns. */
|
|
39
|
+
export async function withWorktree<T>(
|
|
40
|
+
repoCwd: string,
|
|
41
|
+
fn: (worktreeDir: string) => Promise<T>,
|
|
42
|
+
deps: WorktreeDeps = {},
|
|
43
|
+
): Promise<T> {
|
|
44
|
+
const spawn = deps.spawn ?? defaultGitSpawn();
|
|
45
|
+
const warn = deps.warn ?? ((m: string) => console.error(m));
|
|
46
|
+
|
|
47
|
+
const top = await spawn(["rev-parse", "--show-toplevel"], repoCwd);
|
|
48
|
+
if (top.code !== 0) {
|
|
49
|
+
warn(`omw(worktree): ${repoCwd} is not a git repo; running the node in place.`);
|
|
50
|
+
return fn(repoCwd);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const dir = join(repoCwd, ".omw-worktrees", `wt-${process.pid}-${++wtCounter}`);
|
|
54
|
+
const add = await spawn(["worktree", "add", "--detach", dir], repoCwd);
|
|
55
|
+
if (add.code !== 0) {
|
|
56
|
+
warn(`omw(worktree): \`git worktree add\` failed (${add.stderr.trim()}); running in place.`);
|
|
57
|
+
return fn(repoCwd);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return await fn(dir);
|
|
62
|
+
} finally {
|
|
63
|
+
// Auto-remove only when the node left the worktree clean; otherwise keep it
|
|
64
|
+
// so the changes aren't silently discarded.
|
|
65
|
+
const status = await spawn(["status", "--porcelain"], dir);
|
|
66
|
+
if (status.code === 0 && status.stdout.trim() === "") {
|
|
67
|
+
await spawn(["worktree", "remove", "--force", dir], repoCwd);
|
|
68
|
+
} else {
|
|
69
|
+
warn(`omw(worktree): ${dir} has uncommitted changes; leaving it for inspection.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|