@oisincoveney/pipeline 2.1.1 → 2.2.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.
Files changed (37) hide show
  1. package/defaults/pipeline.yaml +111 -0
  2. package/defaults/profiles.yaml +212 -0
  3. package/defaults/runners.yaml +24 -0
  4. package/dist/argo-graph.js +59 -27
  5. package/dist/argo-submit.js +10 -2
  6. package/dist/claude-settings-config.js +44 -0
  7. package/dist/cli/program.js +10 -2
  8. package/dist/config/defaults.js +7 -350
  9. package/dist/config/schemas.d.ts +5 -5
  10. package/dist/install-commands/claude-code.js +160 -0
  11. package/dist/install-commands/opencode.js +39 -32
  12. package/dist/install-commands/shared.js +32 -5
  13. package/dist/install-commands.js +26 -15
  14. package/dist/json-config-merge.js +47 -0
  15. package/dist/mcp/gateway.js +9 -1
  16. package/dist/moka-submit.d.ts +7 -7
  17. package/dist/opencode-project-config.js +2 -45
  18. package/dist/pipeline-runtime.js +50 -8
  19. package/dist/runner-command-contract.d.ts +2 -2
  20. package/dist/runner-event-schema.d.ts +349 -0
  21. package/dist/runner-event-schema.js +185 -0
  22. package/dist/runner-output.js +2 -2
  23. package/dist/runner.d.ts +2 -0
  24. package/dist/runtime/agent-node/agent-node.js +1 -0
  25. package/dist/runtime/contracts/contracts.d.ts +2 -0
  26. package/dist/runtime/node-state-store.js +7 -0
  27. package/dist/runtime/opencode-adapter.js +28 -8
  28. package/dist/runtime/opencode-agent-name.js +18 -0
  29. package/dist/runtime/opencode-runtime.js +62 -0
  30. package/dist/runtime/opencode-server.js +67 -0
  31. package/dist/runtime/opencode-session-executor.js +206 -0
  32. package/docs/adr-opencode-first-goal-loop-runtime.md +1 -1
  33. package/docs/config-architecture.md +11 -6
  34. package/docs/mcp-gateway.md +4 -4
  35. package/docs/operator-guide.md +7 -5
  36. package/docs/slash-command-adapter-contract.md +1 -0
  37. package/package.json +6 -1
@@ -0,0 +1,62 @@
1
+ import { OpencodeServerStartupError, openOpencodeServer } from "./opencode-server.js";
2
+ import { createOpencodeExecutor, createOpencodeSessionRegistry } from "./opencode-session-executor.js";
3
+ //#region src/runtime/opencode-runtime.ts
4
+ /**
5
+ * True when the config declares any opencode runner, i.e. the SDK transport is
6
+ * relevant for this run. Command-only configs never start a server.
7
+ */
8
+ function configUsesOpencode(config) {
9
+ return Object.values(config.runners).some((runner) => runner.type === "opencode");
10
+ }
11
+ /**
12
+ * Return an SDK-backed executor plus a release() that tears the server down.
13
+ * The opencode server is started LAZILY on the first executor call, not at
14
+ * lease time: a run whose ready nodes are all command/builtin (or that fails
15
+ * before any agent node executes) never spawns opencode, so the binary is only
16
+ * required when an agent node actually runs. release() is a no-op if the server
17
+ * was never started. Caller owns the lifecycle:
18
+ *
19
+ * const lease = await leaseOpencodeRuntime({ config, worktreePath });
20
+ * try { ...run with lease.executor... } finally { await lease.release(); }
21
+ */
22
+ function leaseOpencodeRuntime(input) {
23
+ const registry = createOpencodeSessionRegistry();
24
+ const openHandle = input.openServer ?? startServer;
25
+ let handle;
26
+ let pending;
27
+ const ensureExecutor = () => {
28
+ pending ??= openHandle(input).then((started) => {
29
+ handle = started;
30
+ return createOpencodeExecutor({
31
+ client: started.client,
32
+ directory: input.worktreePath,
33
+ registry
34
+ });
35
+ });
36
+ return pending;
37
+ };
38
+ return Promise.resolve({
39
+ executor: async (plan, options) => {
40
+ return await (await ensureExecutor())(plan, options);
41
+ },
42
+ release: async () => {
43
+ if (handle) {
44
+ await handle.close();
45
+ handle = void 0;
46
+ }
47
+ }
48
+ });
49
+ }
50
+ async function startServer(input) {
51
+ try {
52
+ return await openOpencodeServer({
53
+ directory: input.worktreePath,
54
+ ...input.signal ? { signal: input.signal } : {}
55
+ });
56
+ } catch (error) {
57
+ if (error instanceof OpencodeServerStartupError) throw new OpencodeServerStartupError(`${error.message}. Confirm the opencode binary is installed and recent enough to expose 'opencode serve', or set OPENCODE_SERVER_URL to an already-running server.`, { cause: error });
58
+ throw error;
59
+ }
60
+ }
61
+ //#endregion
62
+ export { configUsesOpencode, leaseOpencodeRuntime };
@@ -0,0 +1,67 @@
1
+ import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk";
2
+ //#region src/runtime/opencode-server.ts
3
+ const DEFAULT_STARTUP_TIMEOUT_MS = 3e4;
4
+ var OpencodeServerStartupError = class extends Error {
5
+ constructor(message, options) {
6
+ super(message, options);
7
+ this.name = "OpencodeServerStartupError";
8
+ }
9
+ };
10
+ async function openOpencodeServer(options) {
11
+ const externalUrl = options.serverUrl ?? process.env.OPENCODE_SERVER_URL ?? "";
12
+ if (externalUrl.length > 0) return connectExternalServer(externalUrl, options.directory);
13
+ return await spawnOwnedServer(options);
14
+ }
15
+ function connectExternalServer(url, directory) {
16
+ return {
17
+ close: () => Promise.resolve(),
18
+ client: createOpencodeClient({
19
+ baseUrl: url,
20
+ directory
21
+ }),
22
+ owned: false,
23
+ url
24
+ };
25
+ }
26
+ async function spawnOwnedServer(options) {
27
+ const spawn = options.spawn ?? createOpencode;
28
+ try {
29
+ const { client, server } = await spawn(spawnArgs(options));
30
+ return ownedHandle(client, server, options.directory);
31
+ } catch (error) {
32
+ throw new OpencodeServerStartupError(`Failed to start opencode server: ${errorText(error)}`, { cause: error });
33
+ }
34
+ }
35
+ function spawnArgs(options) {
36
+ return {
37
+ port: 0,
38
+ ...options.signal ? { signal: options.signal } : {},
39
+ timeout: options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS
40
+ };
41
+ }
42
+ function ownedHandle(client, server, directory) {
43
+ return {
44
+ close: () => {
45
+ server.close();
46
+ return Promise.resolve();
47
+ },
48
+ client: withDirectory(client, server.url, directory),
49
+ owned: true,
50
+ url: server.url
51
+ };
52
+ }
53
+ function errorText(error) {
54
+ return error instanceof Error ? error.message : String(error);
55
+ }
56
+ function withDirectory(fallback, url, directory) {
57
+ try {
58
+ return createOpencodeClient({
59
+ baseUrl: url,
60
+ directory
61
+ });
62
+ } catch {
63
+ return fallback;
64
+ }
65
+ }
66
+ //#endregion
67
+ export { OpencodeServerStartupError, openOpencodeServer };
@@ -0,0 +1,206 @@
1
+ import { opencodeAgentName } from "./opencode-agent-name.js";
2
+ //#region src/runtime/opencode-session-executor.ts
3
+ function createOpencodeSessionRegistry() {
4
+ return { sessions: /* @__PURE__ */ new Map() };
5
+ }
6
+ /**
7
+ * Distinguish infra failure (server/session error -> retry-eligible exit 70)
8
+ * from a normal agent completion (the agent may still have produced a wrong
9
+ * answer; gates decide that, exit 0). This mirrors the EXIT_STARTUP convention
10
+ * in runner-command/run.ts and feeds retry.ts via the node's retry policy.
11
+ */
12
+ const EXIT_OK = 0;
13
+ const EXIT_AGENT_ERROR = 1;
14
+ const EXIT_INFRA = 70;
15
+ /**
16
+ * SDK-backed replacement for the subprocess `runLaunchPlan`. Conforms to the
17
+ * RuntimeContext.executor seam so agent-node never learns the transport.
18
+ */
19
+ function createOpencodeExecutor(deps) {
20
+ return async function execute(plan, options = {}) {
21
+ if (plan.type !== "opencode") throw new Error(`opencode executor cannot drive runner type '${plan.type}'`);
22
+ try {
23
+ return successResult(plan, await driveSession(deps, plan, options));
24
+ } catch (error) {
25
+ return failureResult(plan, error);
26
+ }
27
+ };
28
+ }
29
+ async function driveSession(deps, plan, options) {
30
+ const sessionId = await resolveSessionId(deps, plan);
31
+ deps.onSession?.(plan.nodeId, sessionId);
32
+ const stream = await streamEventsToOutput(deps, sessionId, plan, options);
33
+ try {
34
+ const data = unwrap(await deps.client.session.prompt({
35
+ body: promptBody(plan),
36
+ path: { id: sessionId },
37
+ query: { directory: deps.directory }
38
+ }));
39
+ return {
40
+ ...data.info ? { assistant: data.info } : {},
41
+ parts: data.parts ?? [],
42
+ sessionId
43
+ };
44
+ } finally {
45
+ await stream.stop();
46
+ }
47
+ }
48
+ async function resolveSessionId(deps, plan) {
49
+ const existing = deps.registry.sessions.get(plan.nodeId);
50
+ if (existing) return existing;
51
+ const session = unwrap(await deps.client.session.create({
52
+ body: { title: `moka:${plan.nodeId}` },
53
+ query: { directory: deps.directory }
54
+ }));
55
+ deps.registry.sessions.set(plan.nodeId, session.id);
56
+ return session.id;
57
+ }
58
+ function promptBody(plan) {
59
+ const prompt = promptText(plan);
60
+ const model = parseModel(plan.model);
61
+ const agent = plan.profileId ? opencodeAgentName(plan.profileId) : void 0;
62
+ return {
63
+ parts: [{
64
+ text: prompt,
65
+ type: "text"
66
+ }],
67
+ ...agent ? { agent } : {},
68
+ ...model ? { model } : {}
69
+ };
70
+ }
71
+ const FLAGS_TAKING_VALUE = new Set([
72
+ "--model",
73
+ "--dir",
74
+ "--file",
75
+ "--format"
76
+ ]);
77
+ /**
78
+ * The launch plan carries the prompt inside the CLI argv (`run <prompt>` or
79
+ * `run <prompt> --file <ctx>`). Recover it as the trailing positional arg
80
+ * (skipping flags and their values) so the adapter boundary is identical
81
+ * regardless of transport.
82
+ */
83
+ function promptText(plan) {
84
+ return plan.args.filter((arg, index) => index > 0 && !arg.startsWith("-") && !FLAGS_TAKING_VALUE.has(plan.args[index - 1] ?? "")).at(-1) ?? "";
85
+ }
86
+ function parseModel(model) {
87
+ if (!model) return;
88
+ const slash = model.indexOf("/");
89
+ if (slash === -1) return;
90
+ return {
91
+ modelID: model.slice(slash + 1),
92
+ providerID: model.slice(0, slash)
93
+ };
94
+ }
95
+ async function streamEventsToOutput(deps, sessionId, plan, options) {
96
+ if (!options.onOutput) return { stop: () => Promise.resolve() };
97
+ const iterator = (await deps.client.event.subscribe()).stream;
98
+ const pump = (async () => {
99
+ try {
100
+ for await (const event of iterator) forwardEvent(event, sessionId, plan, options);
101
+ } catch (error) {
102
+ options.onOutput?.({
103
+ chunk: `opencode event stream dropped: ${errorMessage(error)}\n`,
104
+ nodeId: plan.nodeId,
105
+ stream: "stderr"
106
+ });
107
+ }
108
+ })();
109
+ return { stop: async () => {
110
+ await iterator.return?.(void 0);
111
+ await pump;
112
+ } };
113
+ }
114
+ function forwardEvent(event, sessionId, plan, options) {
115
+ const forwarded = eventChunk(event, sessionId);
116
+ if (forwarded) options.onOutput?.({
117
+ chunk: forwarded.chunk,
118
+ nodeId: plan.nodeId,
119
+ stream: forwarded.stream
120
+ });
121
+ }
122
+ function eventChunk(event, sessionId) {
123
+ if (event.type === "message.part.updated") return partChunk(event.properties.part, sessionId);
124
+ if (event.type === "session.error" && belongsToSession(event, sessionId)) return {
125
+ chunk: `opencode session error: ${describeMessageError(event.properties.error)}\n`,
126
+ stream: "stderr"
127
+ };
128
+ }
129
+ function belongsToSession(event, sessionId) {
130
+ return event.properties.sessionID === void 0 || event.properties.sessionID === sessionId;
131
+ }
132
+ function partChunk(part, sessionId) {
133
+ if (part.sessionID !== sessionId) return;
134
+ if (part.type === "text") return {
135
+ chunk: `${JSON.stringify({ part: {
136
+ text: part.text,
137
+ type: "text"
138
+ } })}\n`,
139
+ stream: "stdout"
140
+ };
141
+ if (part.type === "tool") return {
142
+ chunk: `opencode tool ${part.tool} ${part.state.status}\n`,
143
+ stream: "stderr"
144
+ };
145
+ }
146
+ /**
147
+ * Reconstruct the JSONL stdout the existing normalizeOutput/outputCandidates
148
+ * parser already understands, so the structured-output and repair passes work
149
+ * unchanged on top of SDK responses.
150
+ */
151
+ function successResult(plan, drive) {
152
+ const stdout = drive.parts.filter((part) => part.type === "text").map((part) => JSON.stringify({ part: {
153
+ text: part.text,
154
+ type: "text"
155
+ } })).join("\n");
156
+ const assistantError = drive.assistant?.error;
157
+ if (assistantError) return {
158
+ argv: plan.args,
159
+ exitCode: infraErrorExitCode(assistantError),
160
+ sessionId: drive.sessionId,
161
+ stderr: describeMessageError(assistantError),
162
+ stdout
163
+ };
164
+ return {
165
+ argv: plan.args,
166
+ exitCode: EXIT_OK,
167
+ sessionId: drive.sessionId,
168
+ stdout
169
+ };
170
+ }
171
+ function failureResult(plan, error) {
172
+ return {
173
+ argv: plan.args,
174
+ exitCode: EXIT_INFRA,
175
+ stderr: `opencode session failed: ${errorMessage(error)}`,
176
+ stdout: ""
177
+ };
178
+ }
179
+ /**
180
+ * Map opencode message errors to the runner's infra-vs-agent exit convention.
181
+ * Output-length / aborted are agent-task outcomes (exit 1, gate territory);
182
+ * provider-auth, API, and unknown are infra (exit 70, retry-eligible).
183
+ */
184
+ function infraErrorExitCode(error) {
185
+ switch (error.name) {
186
+ case "MessageOutputLengthError":
187
+ case "MessageAbortedError": return EXIT_AGENT_ERROR;
188
+ default: return EXIT_INFRA;
189
+ }
190
+ }
191
+ function describeMessageError(error) {
192
+ if (!error) return "unknown opencode error";
193
+ const data = error.data;
194
+ const detail = data && typeof data.message === "string" ? `: ${data.message}` : "";
195
+ return `${error.name}${detail}`;
196
+ }
197
+ function unwrap(response) {
198
+ if (response.error) throw new Error(typeof response.error === "string" ? response.error : JSON.stringify(response.error));
199
+ if (response.data === void 0) throw new Error("opencode response contained no data");
200
+ return response.data;
201
+ }
202
+ function errorMessage(error) {
203
+ return error instanceof Error ? error.message : String(error);
204
+ }
205
+ //#endregion
206
+ export { createOpencodeExecutor, createOpencodeSessionRegistry };
@@ -1,6 +1,6 @@
1
1
  # ADR: OpenCode-First Goal Loop Runtime
2
2
 
3
- Status: Accepted
3
+ Status: Accepted (Codex compatibility subsequently removed; OpenCode and Claude Code are the active command hosts)
4
4
 
5
5
  Date: 2026-06-08
6
6
 
@@ -306,14 +306,19 @@ timeouts, output limits, sanitized env, and explicit trust flags.
306
306
 
307
307
  ## Host Support Matrix
308
308
 
309
- | Runner | Native subagents | Rules | Skills | MCP | Outputs | Generated resources |
310
- | -------- | ---------------- | ----- | ------ | --- | ------------------------- | ------------------------------- |
311
- | OpenCode | yes | yes | yes | yes | text, JSON, JSONL, schema | commands, agents, skills, plugins, LSP |
312
- | command | no | no | no | no | declared by runner | subprocess argv |
309
+ | Runner | Native subagents | Rules | Skills | MCP | Outputs | Generated resources |
310
+ | ----------- | ---------------- | ----- | ------ | --- | ------------------------- | ------------------------------- |
311
+ | OpenCode | yes | yes | yes | yes | text, JSON, JSONL, schema | commands, agents, skills, plugins, LSP |
312
+ | Claude Code | via `opencode run` | yes (skill) | yes | yes | declared by runner | commands, wrapper agents, settings |
313
+ | command | no | no | no | no | declared by runner | subprocess argv |
313
314
 
314
315
  Generated host resources follow a native runner rule. OpenCode runner nodes use
315
- OpenCode native agents. Unsupported runner or host mappings fail closed instead
316
- of doing instruction-only translation or generic worker substitution.
316
+ OpenCode native agents. The Claude Code host does not run MoKa profiles natively:
317
+ its generated `.claude/agents/moka-<role>.md` subagents each wrap a single
318
+ `opencode run --agent "MoKa <role>"` subprocess, so the OpenCode runner remains
319
+ the execution surface while Claude Code orchestrates. Unsupported runner or host
320
+ mappings fail closed instead of doing instruction-only translation or generic
321
+ worker substitution.
317
322
 
318
323
  ## Troubleshooting
319
324
 
@@ -9,7 +9,7 @@ Playwright, Qdrant, or Neon.
9
9
  ## Target Shape
10
10
 
11
11
  ```text
12
- Codex/OpenCode
12
+ OpenCode / Claude Code
13
13
  |
14
14
  | project-level MCP config
15
15
  v
@@ -47,7 +47,7 @@ host-specific MCP config.
47
47
  3. Configure `mcp_gateway` in package-owned profile config.
48
48
  4. Run `moka mcp gateway reconcile` to render and apply the full ToolHive vMCP
49
49
  backend inventory for the current workspace.
50
- 5. Run `moka init` to write project Codex/OpenCode command surfaces and host
50
+ 5. Run `moka init` to write project OpenCode and Claude Code command surfaces and host
51
51
  MCP config.
52
52
  6. Run `moka mcp gateway doctor` to verify gateway health and required tools.
53
53
  7. Keep high-risk upstream capabilities controlled by gateway-side policy, not
@@ -88,7 +88,7 @@ orchestrator MCP set * subagent count * host config layers
88
88
  ```
89
89
 
90
90
  With a gateway, the runtime launches zero local upstream MCP processes for
91
- agents. Codex and OpenCode read the same project-level host config, which
91
+ agents. OpenCode and Claude Code read the same project-level host config, which
92
92
  contains only `pipeline-gateway`.
93
93
 
94
94
  OpenCode receives that gateway through `.opencode/opencode.json` alongside the
@@ -114,7 +114,7 @@ adding one backend must not replace the existing Context7, uidotsh, Qdrant,
114
114
  Fallow, Serena, or Backlog declarations. Use `moka mcp gateway doctor` to check
115
115
  required environment variables, gateway health, required `tools/list` prefixes,
116
116
  local ToolHive availability for local mode, and legacy direct MCP entries. Use
117
- `moka init` to install generated Codex and OpenCode host surfaces with the
117
+ `moka init` to install generated OpenCode and Claude Code host surfaces with the
118
118
  singleton `pipeline-gateway` remote entry. Use `moka install-commands --host all`
119
119
  to refresh generated host files after package upgrades, and use
120
120
  `moka mcp gateway configure-host` as an explicit migration or repair command
@@ -302,8 +302,8 @@ Troubleshooting:
302
302
  Generated invocations include:
303
303
 
304
304
  ```text
305
- OpenCode: /moka-quick, /moka-execute, /moka-inspect
306
- Codex: $quick, $execute, $inspect
305
+ OpenCode: /moka-quick, /moka-execute, /moka-inspect
306
+ Claude Code: /moka-quick, /moka-execute, /moka-inspect
307
307
  ```
308
308
 
309
309
  `moka init` and `moka install-commands --host opencode` generate:
@@ -316,9 +316,11 @@ Codex: $quick, $execute, $inspect
316
316
  - `.opencode/opencode.json` with LSP, the singleton `pipeline-gateway` MCP
317
317
  server, and pinned package-selected plugins
318
318
 
319
+ `moka install-commands --host claude-code` generates `.claude/commands/moka-<entrypoint>.md`
320
+ slash commands for Claude Code.
321
+
319
322
  Package defaults select OpenCode for built-in profiles and runner-command
320
- orchestration. Codex compatibility stays generated through `$quick`, `$execute`,
321
- `$inspect`, and Codex agent config, but it is not the default package runtime.
323
+ orchestration. Codex is not a supported runtime host.
322
324
 
323
325
  ## How The Package Works
324
326
 
@@ -373,7 +375,7 @@ profiles, workflows, or node-level skill overrides.
373
375
 
374
376
  ## Configuring MCP Gateway
375
377
 
376
- MCP access is host-level and gateway-only. Codex, OpenCode, pipeline agents,
378
+ MCP access is host-level and gateway-only. OpenCode, Claude Code, pipeline agents,
377
379
  manual sessions, and CI all connect to one ToolHive/vMCP gateway URL. Do not
378
380
  start upstream MCP servers from individual sessions.
379
381
 
@@ -16,6 +16,7 @@ moka install-commands --host all --check
16
16
  | Host | Generated resources | Invocation | Mechanical path |
17
17
  | -------- | -------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
18
18
  | OpenCode | `.opencode/commands/moka-<entrypoint>.md`, `.opencode/agents/*.md`, `.opencode/opencode.json` | `/moka-quick <task>`, `/moka-execute <task>`, `/moka-inspect <task>` | Project commands run a primary orchestrator and OpenCode native subagents with package-owned skill, MCP, permission, and LSP projection. |
19
+ | Claude Code | `.claude/commands/moka-<entrypoint>.md`, `.claude/agents/moka-<role>.md`, `.claude/settings.json` | `/moka-quick <task>`, `/moka-execute <task>`, `/moka-inspect <task>` | Slash commands load the `execute` skill, then dispatch each agent node to a Claude Code `Task` subagent that wraps a single `opencode run --agent "MoKa <role>"` subprocess. `.claude/settings.json` is merged (gateway MCP + `Bash(opencode run *)` permission), never clobbered. |
19
20
 
20
21
  ## Projection Rules
21
22
 
package/package.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "dependencies": {
3
3
  "@dagrejs/graphlib": "^4.0.1",
4
4
  "@kubernetes/client-node": "^1.4.0",
5
+ "@opencode-ai/sdk": "1.17.4",
5
6
  "ajv": "^8.20.0",
6
7
  "ajv-formats": "^3.0.1",
7
8
  "commander": "^14.0.3",
@@ -62,6 +63,10 @@
62
63
  "types": "./dist/config.d.ts",
63
64
  "import": "./dist/config.js"
64
65
  },
66
+ "./events": {
67
+ "types": "./dist/runner-event-schema.d.ts",
68
+ "import": "./dist/runner-event-schema.js"
69
+ },
65
70
  "./hooks": {
66
71
  "types": "./dist/hooks.d.ts",
67
72
  "import": "./dist/hooks.js"
@@ -115,7 +120,7 @@
115
120
  "prepack": "bun run build:cli"
116
121
  },
117
122
  "type": "module",
118
- "version": "2.1.1",
123
+ "version": "2.2.0",
119
124
  "description": "Config-driven multi-agent pipeline runner for repository work",
120
125
  "main": "./dist/index.js",
121
126
  "types": "./dist/index.d.ts",