oh-my-workflow 0.2.0 → 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 +191 -106
- 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 +247 -137
- 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/adapters/claude.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Spawn is injected so the parse/argv logic is tested without a subprocess or an
|
|
9
9
|
// API call; the default spawn uses Bun.spawn and is exercised live under OMW_LIVE.
|
|
10
10
|
|
|
11
|
-
import type { AgentPort, AgentResult, InvokeRequest } from "./types";
|
|
11
|
+
import type { AgentPort, AgentResult, FollowUpOpts, InvokeRequest } from "./types";
|
|
12
12
|
|
|
13
13
|
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
14
14
|
|
|
@@ -18,6 +18,9 @@ const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(
|
|
|
18
18
|
export function parseClaudeResult(raw: unknown): AgentResult {
|
|
19
19
|
const j = raw as Record<string, unknown> | null;
|
|
20
20
|
const durationMs = Number(j?.duration_ms) || 0;
|
|
21
|
+
// Tokens are surfaced on success AND failure: an error/refusal envelope still
|
|
22
|
+
// carries `usage`, so budget accounting counts a failed node's real spend.
|
|
23
|
+
const outputTokens = (j?.usage as { output_tokens?: number } | undefined)?.output_tokens;
|
|
21
24
|
|
|
22
25
|
// A safety/decline refusal (stop_reason "refusal") is a journaled DECLINE — not
|
|
23
26
|
// a crash, and not a real answer. Classify it FIRST, before the is_error/subtype
|
|
@@ -33,14 +36,14 @@ export function parseClaudeResult(raw: unknown): AgentResult {
|
|
|
33
36
|
ok: false,
|
|
34
37
|
kind: "refusal",
|
|
35
38
|
stderr: `refusal${category ? `(${category})` : ""}: ${detail}`.trim(),
|
|
36
|
-
meta: { durationMs },
|
|
39
|
+
meta: { durationMs, outputTokens },
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
if (!j || j.type !== "result" || j.is_error === true || j.subtype !== "success") {
|
|
41
44
|
const subtype = (j?.subtype ?? j?.type ?? "unknown") as string;
|
|
42
45
|
const detail = typeof j?.result === "string" ? j.result : "";
|
|
43
|
-
return { ok: false, kind: "nonzero_exit", stderr: `${subtype}: ${detail}`.trim(), meta: { durationMs } };
|
|
46
|
+
return { ok: false, kind: "nonzero_exit", stderr: `${subtype}: ${detail}`.trim(), meta: { durationMs, outputTokens } };
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
return {
|
|
@@ -50,6 +53,7 @@ export function parseClaudeResult(raw: unknown): AgentResult {
|
|
|
50
53
|
durationMs,
|
|
51
54
|
sessionId: j.session_id as string | undefined,
|
|
52
55
|
costUsd: j.total_cost_usd as number | undefined,
|
|
56
|
+
outputTokens,
|
|
53
57
|
},
|
|
54
58
|
};
|
|
55
59
|
}
|
|
@@ -64,6 +68,9 @@ export type ClaudeAdapterDeps = {
|
|
|
64
68
|
spawn?: ClaudeSpawn;
|
|
65
69
|
/** Binary name/path; defaults to "claude" on PATH. */
|
|
66
70
|
bin?: string;
|
|
71
|
+
/** Diagnostic sink for honest-scope notices (e.g. an opt with no faithful CLI
|
|
72
|
+
* flag was dropped). Defaults to console.error. */
|
|
73
|
+
warn?: (msg: string) => void;
|
|
67
74
|
};
|
|
68
75
|
|
|
69
76
|
/** Default spawn over Bun.spawn. Kills the child after timeoutMs and flags it so
|
|
@@ -95,6 +102,15 @@ function defaultSpawn(bin: string): ClaudeSpawn {
|
|
|
95
102
|
|
|
96
103
|
export function makeClaudeAdapter(deps: ClaudeAdapterDeps = {}): AgentPort {
|
|
97
104
|
const spawn = deps.spawn ?? defaultSpawn(deps.bin ?? "claude");
|
|
105
|
+
const warn = deps.warn ?? ((m: string) => console.error(m));
|
|
106
|
+
// One-time per field: claude -p has no faithful flag for these yet, so they are
|
|
107
|
+
// dropped rather than silently honored. Warn once so a fan-out isn't spammed.
|
|
108
|
+
const warnedFields = new Set<string>();
|
|
109
|
+
const warnUnmapped = (field: string, value: unknown) => {
|
|
110
|
+
if (warnedFields.has(field)) return;
|
|
111
|
+
warnedFields.add(field);
|
|
112
|
+
warn(`omw(claude): \`${field}\` (=${String(value)}) has no claude -p flag; dropped for this run.`);
|
|
113
|
+
};
|
|
98
114
|
|
|
99
115
|
async function run(args: string[], cwd?: string, timeoutMs?: number): Promise<AgentResult> {
|
|
100
116
|
let res: ClaudeSpawnResult;
|
|
@@ -136,11 +152,21 @@ export function makeClaudeAdapter(deps: ClaudeAdapterDeps = {}): AgentPort {
|
|
|
136
152
|
invoke(req: InvokeRequest): Promise<AgentResult> {
|
|
137
153
|
const args = ["-p", req.prompt, "--output-format", "json"];
|
|
138
154
|
if (req.model) args.push("--model", req.model);
|
|
155
|
+
if (req.effort !== undefined) warnUnmapped("effort", req.effort);
|
|
156
|
+
if (req.agentType !== undefined) warnUnmapped("agentType", req.agentType);
|
|
157
|
+
// Isolate the node from the host's MCP servers unless asked otherwise:
|
|
158
|
+
// booting figma/devtools/etc. on every node is the dominant fan-out latency.
|
|
159
|
+
if (!req.inheritMcp) args.push("--strict-mcp-config");
|
|
139
160
|
return run(args, req.cwd, req.timeoutMs);
|
|
140
161
|
},
|
|
141
|
-
|
|
162
|
+
// `cwd` must match the original invoke — claude keys session history by
|
|
163
|
+
// project directory, so resuming elsewhere yields "No conversation found".
|
|
164
|
+
// MCP isolation must mirror the original invoke so the resume turn sees the
|
|
165
|
+
// same environment as the turn it continues.
|
|
166
|
+
followUp(sessionId: string, prompt: string, opts?: FollowUpOpts): Promise<AgentResult> {
|
|
142
167
|
const args = ["-p", prompt, "--resume", sessionId, "--output-format", "json"];
|
|
143
|
-
|
|
168
|
+
if (!opts?.inheritMcp) args.push("--strict-mcp-config");
|
|
169
|
+
return run(args, opts?.cwd, opts?.timeoutMs);
|
|
144
170
|
},
|
|
145
171
|
};
|
|
146
172
|
}
|
package/src/adapters/codex.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// ACTIONABLY (surfaces the reason) rather than silently returning empty — the
|
|
12
12
|
// authoring agent can read WHY in the journal.
|
|
13
13
|
|
|
14
|
-
import type { AgentPort, AgentResult, InvokeRequest } from "./types";
|
|
14
|
+
import type { AgentPort, AgentResult, FollowUpOpts, InvokeRequest } from "./types";
|
|
15
15
|
import type { ClaudeSpawn as Spawn, ClaudeSpawnResult as SpawnResult } from "./claude";
|
|
16
16
|
|
|
17
17
|
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
@@ -141,9 +141,11 @@ export function makeCodexAdapter(deps: CodexAdapterDeps = {}): AgentPort {
|
|
|
141
141
|
args.push(req.prompt);
|
|
142
142
|
return run(args, req.cwd, req.timeoutMs);
|
|
143
143
|
},
|
|
144
|
-
|
|
144
|
+
// `cwd` must match the original invoke so resume finds the session.
|
|
145
|
+
// (MCP isolation / inheritMcp is not yet implemented for codex.)
|
|
146
|
+
followUp(sessionId: string, prompt: string, opts?: FollowUpOpts): Promise<AgentResult> {
|
|
145
147
|
const args = ["exec", "resume", sessionId, "--json", "-s", sandbox, prompt];
|
|
146
|
-
return run(args);
|
|
148
|
+
return run(args, opts?.cwd, opts?.timeoutMs);
|
|
147
149
|
},
|
|
148
150
|
};
|
|
149
151
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Config-driven adapter for the simple "prompt in → response text out" coding-agent
|
|
2
|
+
// CLI shape. Every adapter repeats the same spawn / timeout / exit-code / parse
|
|
3
|
+
// boilerplate; this collapses it so a plain one-shot CLI is a few lines of config
|
|
4
|
+
// instead of a hand-written ~100-line file.
|
|
5
|
+
//
|
|
6
|
+
// Use a dedicated adapter (claude.ts, codex.ts) when a CLI has real quirks the
|
|
7
|
+
// generic shape can't carry: a JSON/JSONL envelope, in-session resume (`followUp`,
|
|
8
|
+
// which the schema gate uses for self-repair), a distinct refusal signal, or a
|
|
9
|
+
// cost field. This generic adapter has NO followUp on purpose — a config-only CLI
|
|
10
|
+
// that prints plain text has no session id to resume, so the schema gate falls
|
|
11
|
+
// back to fresh invokes (the documented no-followUp path).
|
|
12
|
+
|
|
13
|
+
import type { AgentPort, AgentResult, InvokeRequest } from "./types";
|
|
14
|
+
import type { ClaudeSpawn as Spawn, ClaudeSpawnResult as SpawnResult } from "./claude";
|
|
15
|
+
|
|
16
|
+
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
17
|
+
|
|
18
|
+
export type ExecAdapterConfig = {
|
|
19
|
+
/** Adapter name, surfaced as AgentResult source + in resolveAdapter. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Default binary; overridable via deps.bin. */
|
|
22
|
+
bin: string;
|
|
23
|
+
/** Build the args (after the bin) from the request. */
|
|
24
|
+
argv: (req: { prompt: string; model?: string }) => string[];
|
|
25
|
+
/** Turn stdout into the response text. Default: trimmed stdout. Returning an
|
|
26
|
+
* empty string is treated as "no output" (a terminal failure). */
|
|
27
|
+
parse?: (stdout: string) => string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ExecAdapterDeps = {
|
|
31
|
+
spawn?: Spawn;
|
|
32
|
+
/** Binary name/path; defaults to the config's `bin`. */
|
|
33
|
+
bin?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Shared spawn boilerplate (Bun.spawn + timeout kill + stdout/stderr capture).
|
|
37
|
+
* Exported so simple adapters reuse it instead of re-implementing it. */
|
|
38
|
+
export function defaultExecSpawn(bin: string): Spawn {
|
|
39
|
+
return async (args, opts) => {
|
|
40
|
+
const proc = Bun.spawn([bin, ...args], {
|
|
41
|
+
cwd: opts?.cwd,
|
|
42
|
+
stdin: "ignore", // one-shot mode; don't block waiting on stdin
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
});
|
|
46
|
+
let timedOut = false;
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
if (opts?.timeoutMs && opts.timeoutMs > 0) {
|
|
49
|
+
timer = setTimeout(() => {
|
|
50
|
+
timedOut = true;
|
|
51
|
+
proc.kill();
|
|
52
|
+
}, opts.timeoutMs);
|
|
53
|
+
}
|
|
54
|
+
const [stdout, stderr] = await Promise.all([
|
|
55
|
+
new Response(proc.stdout).text(),
|
|
56
|
+
new Response(proc.stderr).text(),
|
|
57
|
+
]);
|
|
58
|
+
const code = await proc.exited;
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
return { code, stdout, stderr, timedOut };
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Map a spawn result onto AgentResult for a plain-text CLI: parsed stdout IS the
|
|
65
|
+
* response. A timeout, non-zero exit, or empty parse is a terminal failure (no
|
|
66
|
+
* distinct refusal signal — a soft decline arrives as normal text). */
|
|
67
|
+
export function parseExecResult(res: SpawnResult, parse: (s: string) => string, name: string): AgentResult {
|
|
68
|
+
if (res.timedOut) {
|
|
69
|
+
return { ok: false, kind: "timeout", stderr: res.stderr || `${name} timed out`, meta: { durationMs: 0 } };
|
|
70
|
+
}
|
|
71
|
+
const text = parse(res.stdout).trim();
|
|
72
|
+
if (res.code !== 0) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
kind: "nonzero_exit",
|
|
76
|
+
stderr: res.stderr || text || `${name} exited ${res.code}`,
|
|
77
|
+
meta: { durationMs: 0 },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!text) {
|
|
81
|
+
return { ok: false, kind: "nonzero_exit", stderr: res.stderr || `${name} produced no output`, meta: { durationMs: 0 } };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, text, meta: { durationMs: 0 } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function makeExecAdapter(config: ExecAdapterConfig, deps: ExecAdapterDeps = {}): AgentPort {
|
|
87
|
+
const spawn = deps.spawn ?? defaultExecSpawn(deps.bin ?? config.bin);
|
|
88
|
+
const parse = config.parse ?? ((s: string) => s);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
name: config.name,
|
|
92
|
+
async invoke(req: InvokeRequest): Promise<AgentResult> {
|
|
93
|
+
let res: SpawnResult;
|
|
94
|
+
try {
|
|
95
|
+
res = await spawn(config.argv({ prompt: req.prompt, model: req.model }), { cwd: req.cwd, timeoutMs: req.timeoutMs });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { ok: false, kind: "spawn_failure", stderr: errMsg(e), meta: { durationMs: 0 } };
|
|
98
|
+
}
|
|
99
|
+
return parseExecResult(res, parse, config.name);
|
|
100
|
+
},
|
|
101
|
+
// No followUp: a plain-text CLI has no session id to resume — see header.
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/adapters/fake.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import type { AgentPort, AgentResult, AgentFailureKind, InvokeRequest } from "./types";
|
|
8
8
|
|
|
9
9
|
export type FakeResponse =
|
|
10
|
-
| { text: string; sessionId?: string; costUsd?: number }
|
|
11
|
-
| { fail: AgentFailureKind; stderr?: string };
|
|
10
|
+
| { text: string; sessionId?: string; costUsd?: number; outputTokens?: number }
|
|
11
|
+
| { fail: AgentFailureKind; stderr?: string; outputTokens?: number };
|
|
12
12
|
|
|
13
13
|
export type FakeRule = {
|
|
14
14
|
match: (prompt: string) => boolean;
|
|
@@ -23,12 +23,12 @@ export type FakeAdapterOptions = {
|
|
|
23
23
|
|
|
24
24
|
function toResult(r: FakeResponse, durationMs: number): AgentResult {
|
|
25
25
|
if ("fail" in r) {
|
|
26
|
-
return { ok: false, kind: r.fail, stderr: r.stderr, meta: { durationMs } };
|
|
26
|
+
return { ok: false, kind: r.fail, stderr: r.stderr, meta: { durationMs, outputTokens: r.outputTokens } };
|
|
27
27
|
}
|
|
28
28
|
return {
|
|
29
29
|
ok: true,
|
|
30
30
|
text: r.text,
|
|
31
|
-
meta: { durationMs, sessionId: r.sessionId, costUsd: r.costUsd },
|
|
31
|
+
meta: { durationMs, sessionId: r.sessionId, costUsd: r.costUsd, outputTokens: r.outputTokens },
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The hermes adapter (EXPERIMENTAL) — now just a config over the generic
|
|
2
|
+
// exec-adapter (src/adapters/exec.ts). `hermes -z/--oneshot <prompt>` prints ONLY
|
|
3
|
+
// the agent's final response text to stdout, so stdout IS the result (omw's
|
|
4
|
+
// schema gate extracts JSON from it when a `schema` is set). `--yolo` runs the
|
|
5
|
+
// node non-interactively. No in-session followUp (no session id on stdout) → the
|
|
6
|
+
// schema gate falls back to fresh invokes. No cost field.
|
|
7
|
+
|
|
8
|
+
import type { AgentPort } from "./types";
|
|
9
|
+
import { makeExecAdapter, type ExecAdapterConfig, type ExecAdapterDeps } from "./exec";
|
|
10
|
+
|
|
11
|
+
export const hermesExec: ExecAdapterConfig = {
|
|
12
|
+
name: "hermes",
|
|
13
|
+
bin: "hermes",
|
|
14
|
+
// `--yolo` so a headless node isn't blocked on tool-confirmation prompts.
|
|
15
|
+
argv: ({ prompt, model }) => {
|
|
16
|
+
const args = ["-z", prompt, "--yolo"];
|
|
17
|
+
if (model) args.push("-m", model);
|
|
18
|
+
return args;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function makeHermesAdapter(deps: ExecAdapterDeps = {}): AgentPort {
|
|
23
|
+
return makeExecAdapter(hermesExec, deps);
|
|
24
|
+
}
|
package/src/adapters/types.ts
CHANGED
|
@@ -17,13 +17,19 @@ export type AgentResult =
|
|
|
17
17
|
/** Present when the adapter supports session resume (claude --resume). */
|
|
18
18
|
sessionId?: string;
|
|
19
19
|
costUsd?: number;
|
|
20
|
+
/** Output tokens this node produced, when the adapter reports them.
|
|
21
|
+
* Feeds budget accounting (the shared spend counter). */
|
|
22
|
+
outputTokens?: number;
|
|
20
23
|
};
|
|
21
24
|
}
|
|
22
25
|
| {
|
|
23
26
|
ok: false;
|
|
24
27
|
kind: AgentFailureKind;
|
|
25
28
|
stderr?: string;
|
|
26
|
-
|
|
29
|
+
/** A failure can still report tokens (an error/refusal envelope often
|
|
30
|
+
* carries `usage`), so budget accounting counts it. A token-less failure
|
|
31
|
+
* (e.g. a killed timeout) simply omits it. */
|
|
32
|
+
meta?: { durationMs: number; outputTokens?: number };
|
|
27
33
|
};
|
|
28
34
|
|
|
29
35
|
export type InvokeRequest = {
|
|
@@ -32,12 +38,36 @@ export type InvokeRequest = {
|
|
|
32
38
|
model?: string;
|
|
33
39
|
cwd?: string;
|
|
34
40
|
timeoutMs?: number;
|
|
41
|
+
/** When true, the node inherits the ambient MCP configuration the CLI would
|
|
42
|
+
* normally discover — user/global servers AND the cwd's project `.mcp.json`.
|
|
43
|
+
* Default false → the node runs isolated: booting those servers on every node
|
|
44
|
+
* is the dominant per-node latency in a fan-out, and inheriting them makes a
|
|
45
|
+
* workflow non-reproducible (it behaves differently per machine). A coding-agent
|
|
46
|
+
* node rarely needs them. Honored by the claude adapter (--strict-mcp-config);
|
|
47
|
+
* the codex adapter does not yet implement isolation, so it is a no-op there. */
|
|
48
|
+
inheritMcp?: boolean;
|
|
49
|
+
/** Reasoning-effort hint, passed through to adapters that support it. Adapters
|
|
50
|
+
* with no faithful flag (e.g. claude -p today) drop it and warn once. */
|
|
51
|
+
effort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
52
|
+
/** Cross-vendor node profile (a named agent persona). Adapters map it where
|
|
53
|
+
* they can; otherwise drop + warn once (honest-scope). */
|
|
54
|
+
agentType?: string;
|
|
35
55
|
};
|
|
36
56
|
|
|
57
|
+
/** The subset of InvokeRequest a resume turn must mirror from its original
|
|
58
|
+
* invoke so the repair runs in the same environment and obeys the same bounds. */
|
|
59
|
+
export type FollowUpOpts = Pick<InvokeRequest, "cwd" | "inheritMcp" | "timeoutMs">;
|
|
60
|
+
|
|
37
61
|
export type AgentPort = {
|
|
38
62
|
name: string;
|
|
39
63
|
invoke(req: InvokeRequest): Promise<AgentResult>;
|
|
40
64
|
/** Optional in-session follow-up (claude --resume). When absent, the runtime
|
|
41
|
-
* re-invokes fresh with the error feedback appended to the prompt.
|
|
42
|
-
|
|
65
|
+
* re-invokes fresh with the error feedback appended to the prompt.
|
|
66
|
+
* `opts.cwd` MUST match the original invoke: claude scopes conversation history
|
|
67
|
+
* by project directory, so resuming from a different cwd fails to find the
|
|
68
|
+
* session ("No conversation found"). `opts.inheritMcp` must mirror the
|
|
69
|
+
* original invoke so the resume turn sees the same MCP environment.
|
|
70
|
+
* `opts.timeoutMs` must mirror the original invoke so a schema-repair resume
|
|
71
|
+
* turn cannot hang longer than the node it is repairing. */
|
|
72
|
+
followUp?(sessionId: string, prompt: string, opts?: FollowUpOpts): Promise<AgentResult>;
|
|
43
73
|
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `omw codemod <file> [--to-di|--to-omw] [--write]` — mechanical source rewrites
|
|
2
|
+
// for the 0.4→0.5 migration. `--to-di` (default) converts a legacy positional
|
|
3
|
+
// `(rt, args)` workflow to the destructured-DI surface; `--to-omw` (planned)
|
|
4
|
+
// will wrap a native ambient-global script into an omw default export.
|
|
5
|
+
//
|
|
6
|
+
// These are TEXT transforms on a file the engineer passes — best-effort, printed
|
|
7
|
+
// for review (or written back with --write), never run. A regex codemod can't
|
|
8
|
+
// see through aliasing, so the output is a starting point an author confirms.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import type { Io } from "./run";
|
|
12
|
+
|
|
13
|
+
const HOOKS = "{ agent, parallel, pipeline, phase, log, workflow, budget }";
|
|
14
|
+
|
|
15
|
+
export type Transform = "to-di" | "to-omw";
|
|
16
|
+
|
|
17
|
+
export type CodemodResult = { ok: true; output: string; changed: boolean } | { ok: false; error: string };
|
|
18
|
+
|
|
19
|
+
/** Rewrite a legacy `(rt, args)` default-export workflow to destructured DI:
|
|
20
|
+
* the first param `rt` becomes the hooks object, and `rt.agent(...)` etc. become
|
|
21
|
+
* bare `agent(...)`. Handles `function` and arrow default exports. */
|
|
22
|
+
export function toDi(src: string): CodemodResult {
|
|
23
|
+
const fnRe = /(export\s+default\s+(?:async\s+)?function\s*\*?\s*)\(\s*rt\b\s*(,\s*[^)]*)?\)/;
|
|
24
|
+
const arrowRe = /(export\s+default\s+(?:async\s+)?)\(\s*rt\b\s*(,\s*[^)]*)?\)\s*=>/;
|
|
25
|
+
|
|
26
|
+
let out = src;
|
|
27
|
+
let matched = false;
|
|
28
|
+
if (fnRe.test(out)) {
|
|
29
|
+
out = out.replace(fnRe, (_m, pre, rest) => `${pre}(${HOOKS}${rest ?? ""})`);
|
|
30
|
+
matched = true;
|
|
31
|
+
} else if (arrowRe.test(out)) {
|
|
32
|
+
out = out.replace(arrowRe, (_m, pre, rest) => `${pre}(${HOOKS}${rest ?? ""}) =>`);
|
|
33
|
+
matched = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!matched) {
|
|
37
|
+
return { ok: false, error: "no legacy `(rt, args)` default export found (already destructured-DI?)" };
|
|
38
|
+
}
|
|
39
|
+
// Drop the `rt.` qualifier so hook calls reference the destructured names.
|
|
40
|
+
out = out.replace(/\brt\./g, "");
|
|
41
|
+
return { ok: true, output: out, changed: out !== src };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type CodemodParse =
|
|
45
|
+
| { ok: true; value: { file: string; transform: Transform; write: boolean } }
|
|
46
|
+
| { ok: false; error: string };
|
|
47
|
+
|
|
48
|
+
export function parseCodemodArgs(argv: string[]): CodemodParse {
|
|
49
|
+
let file: string | undefined;
|
|
50
|
+
let transform: Transform = "to-di";
|
|
51
|
+
let write = false;
|
|
52
|
+
for (const tok of argv) {
|
|
53
|
+
if (tok === "--to-di") transform = "to-di";
|
|
54
|
+
else if (tok === "--to-omw") transform = "to-omw";
|
|
55
|
+
else if (tok === "--write") write = true;
|
|
56
|
+
else if (file === undefined) file = tok;
|
|
57
|
+
else return { ok: false, error: `unexpected argument: ${tok}` };
|
|
58
|
+
}
|
|
59
|
+
if (file === undefined) return { ok: false, error: "missing file path" };
|
|
60
|
+
return { ok: true, value: { file, transform, write } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Exit 0 on a successful transform, 1 on a transform/read error, 2 on usage. */
|
|
64
|
+
export async function codemodCommand(argv: string[], io: Io): Promise<number> {
|
|
65
|
+
const parsed = parseCodemodArgs(argv);
|
|
66
|
+
if (!parsed.ok) {
|
|
67
|
+
io.stderr(JSON.stringify({ error: "usage", message: parsed.error }) + "\n");
|
|
68
|
+
io.stderr("usage: omw codemod <file> [--to-di|--to-omw] [--write]\n");
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
const { file, transform, write } = parsed.value;
|
|
72
|
+
|
|
73
|
+
if (transform === "to-omw") {
|
|
74
|
+
io.stderr(JSON.stringify({ error: "not_implemented", message: "--to-omw is not implemented yet; use --to-di" }) + "\n");
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let src: string;
|
|
79
|
+
try {
|
|
80
|
+
src = readFileSync(file, "utf8");
|
|
81
|
+
} catch {
|
|
82
|
+
io.stderr(JSON.stringify({ error: "read_failed", path: file }) + "\n");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = toDi(src);
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
io.stderr(JSON.stringify({ error: "transform_failed", message: result.error, path: file }) + "\n");
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (write) {
|
|
93
|
+
writeFileSync(file, result.output);
|
|
94
|
+
io.stderr(`✓ ${file} — rewrote to destructured DI\n`);
|
|
95
|
+
} else {
|
|
96
|
+
io.stdout(result.output);
|
|
97
|
+
}
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
package/src/cli/omw.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { runCommand } from "./run";
|
|
|
7
7
|
import { replayCommand } from "./replay";
|
|
8
8
|
import { validateCommand } from "./validate";
|
|
9
9
|
import { skillCommand } from "./skill";
|
|
10
|
+
import { codemodCommand } from "./codemod";
|
|
10
11
|
|
|
11
12
|
const io = {
|
|
12
13
|
stdout: (s: string) => process.stdout.write(s),
|
|
@@ -24,14 +25,18 @@ async function main(argv: string[]): Promise<number> {
|
|
|
24
25
|
return validateCommand(rest, io);
|
|
25
26
|
case "skill":
|
|
26
27
|
return skillCommand(rest, io);
|
|
28
|
+
case "codemod":
|
|
29
|
+
return codemodCommand(rest, io);
|
|
27
30
|
default:
|
|
28
31
|
io.stderr(
|
|
29
32
|
"usage: omw <command>\n\n" +
|
|
30
33
|
"commands:\n" +
|
|
31
|
-
" run <workflow> --agent <fake|claude|codex|pi> [--args JSON] [--concurrency N] [--resume <journal
|
|
34
|
+
" run <workflow> [--agent <auto|fake|claude|codex|hermes|pi>] [--args JSON] [--concurrency N] [--budget N] [--resume <journal|runId>] [--strict] [--pretty]\n" +
|
|
32
35
|
" replay <journal.jsonl> [--json]\n" +
|
|
33
36
|
" validate <workflow> [--json]\n" +
|
|
34
|
-
" skill install [--project] install the omw authoring skill for your coding agent\n
|
|
37
|
+
" skill install [--project] install the omw authoring skill for your coding agent\n" +
|
|
38
|
+
" codemod <file> [--to-di] [--write] migrate a legacy (rt, args) workflow to destructured DI\n\n" +
|
|
39
|
+
"agent-authored workflow: omw run .omw/workflows/task.ts\n" +
|
|
35
40
|
"free demo (no API key): omw run examples/deep-research --agent fake\n",
|
|
36
41
|
);
|
|
37
42
|
return cmd === undefined ? 2 : 2;
|