@oisincoveney/pipeline 2.1.1 → 2.3.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/.agents/skills/orchestrate/SKILL.md +80 -0
- package/defaults/pipeline.yaml +111 -0
- package/defaults/profiles.yaml +215 -0
- package/defaults/runners.yaml +24 -0
- package/dist/argo-graph.js +59 -27
- package/dist/argo-submit.d.ts +0 -1
- package/dist/argo-submit.js +11 -6
- package/dist/argo-workflow.d.ts +1 -2
- package/dist/argo-workflow.js +0 -2
- package/dist/claude-settings-config.js +44 -0
- package/dist/cli/program.js +12 -5
- package/dist/cli/submit-options.js +1 -2
- package/dist/cluster-doctor.js +0 -12
- package/dist/commands/pipeline-command.js +1 -1
- package/dist/config/defaults.js +7 -350
- package/dist/config/schemas.d.ts +9 -9
- package/dist/install-commands/claude-code.js +160 -0
- package/dist/install-commands/opencode.js +56 -39
- package/dist/install-commands/shared.js +32 -5
- package/dist/install-commands.js +26 -15
- package/dist/json-config-merge.js +47 -0
- package/dist/mcp/gateway.js +9 -1
- package/dist/moka-global-config.d.ts +0 -1
- package/dist/moka-global-config.js +0 -1
- package/dist/moka-submit.d.ts +8 -11
- package/dist/moka-submit.js +1 -4
- package/dist/opencode-project-config.js +2 -45
- package/dist/pipeline-runtime.d.ts +9 -0
- package/dist/pipeline-runtime.js +50 -8
- package/dist/planned-node.js +2 -5
- package/dist/{workflow-planner.d.ts → planning/compile.d.ts} +2 -2
- package/dist/{workflow-planner.js → planning/compile.js} +6 -83
- package/dist/{schedule/planner.d.ts → planning/generate.d.ts} +17 -3
- package/dist/{schedule/planner.js → planning/generate.js} +24 -56
- package/dist/planning/graph.js +138 -0
- package/dist/runner-command/lifecycle-context.js +2 -3
- package/dist/runner-command/run.js +2 -3
- package/dist/runner-command-contract.d.ts +2 -2
- package/dist/runner-event-schema.d.ts +349 -0
- package/dist/runner-event-schema.js +185 -0
- package/dist/runner-output.js +2 -2
- package/dist/runner.d.ts +29 -0
- package/dist/runtime/agent-node/agent-node.js +1 -0
- package/dist/runtime/context/context.js +1 -1
- package/dist/runtime/contracts/contracts.d.ts +18 -1
- package/dist/runtime/node-state-store.js +7 -0
- package/dist/runtime/opencode-adapter.js +28 -8
- package/dist/runtime/opencode-agent-name.js +18 -0
- package/dist/runtime/opencode-runtime.js +62 -0
- package/dist/runtime/opencode-server.js +67 -0
- package/dist/runtime/opencode-session-executor.js +206 -0
- package/dist/schedule/passes/coverage.js +7 -51
- package/dist/schedule/passes/ids.js +3 -23
- package/dist/schedule/scheduling-roles.js +19 -0
- package/dist/strings.js +30 -1
- package/docs/adr-opencode-first-goal-loop-runtime.md +1 -1
- package/docs/config-architecture.md +43 -6
- package/docs/mcp-gateway.md +4 -4
- package/docs/operator-guide.md +9 -8
- package/docs/pipeline-console-runner-contract.md +3 -4
- package/docs/slash-command-adapter-contract.md +1 -0
- package/package.json +10 -5
- package/dist/schedule-planner.d.ts +0 -2
- package/dist/schedule-planner.js +0 -2
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { isRecord } from "../safe-json.js";
|
|
2
2
|
import { jsonLineValues } from "../json-line-values.js";
|
|
3
3
|
//#region src/runtime/opencode-adapter.ts
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Output-parsing seam for opencode runner output. The SDK executor
|
|
6
|
+
* (opencode-session-executor.ts) re-serializes assistant text parts into the
|
|
7
|
+
* same `{ part: { type: "text", text } }` JSONL the legacy CLI emitted, so this
|
|
8
|
+
* parser is transport-agnostic and the structured-output / repair passes work
|
|
9
|
+
* unchanged on top of SDK responses.
|
|
10
|
+
*
|
|
11
|
+
* The session lifecycle, event-stream forwarding, and per-message agent/model
|
|
12
|
+
* selection live in opencode-session-executor.ts + opencode-server.ts; this
|
|
13
|
+
* adapter only normalizes output and reports capabilities.
|
|
14
|
+
*/
|
|
15
|
+
const opencodeSdkRuntimeAdapter = {
|
|
16
|
+
continuation(request) {
|
|
17
|
+
return Promise.resolve({ metadata: {
|
|
18
|
+
adapterId: "opencode-sdk",
|
|
19
|
+
continuationApi: "session-reuse",
|
|
20
|
+
nodeId: request.sessionId,
|
|
21
|
+
outputFormat: "text",
|
|
22
|
+
pluginEvents: "server-event-stream",
|
|
23
|
+
runnerId: "opencode",
|
|
24
|
+
sessionInspectionApi: "sdk",
|
|
25
|
+
worktreePath: ""
|
|
26
|
+
} });
|
|
7
27
|
},
|
|
8
|
-
id: "opencode-
|
|
28
|
+
id: "opencode-sdk",
|
|
9
29
|
launch(plan) {
|
|
10
30
|
assertOpenCodePlan(plan);
|
|
11
31
|
return {
|
|
@@ -37,13 +57,13 @@ const opencodeCliRuntimeAdapter = {
|
|
|
37
57
|
assertOpenCodePlan(plan);
|
|
38
58
|
return {
|
|
39
59
|
adapterId: this.id,
|
|
40
|
-
continuationApi: "
|
|
60
|
+
continuationApi: "session-reuse",
|
|
41
61
|
nodeId: plan.nodeId,
|
|
42
62
|
outputFormat: plan.outputFormat,
|
|
43
|
-
pluginEvents: "
|
|
63
|
+
pluginEvents: "server-event-stream",
|
|
44
64
|
profileId: plan.profileId,
|
|
45
65
|
runnerId: plan.runnerId,
|
|
46
|
-
sessionInspectionApi: "
|
|
66
|
+
sessionInspectionApi: "sdk",
|
|
47
67
|
worktreePath: plan.cwd
|
|
48
68
|
};
|
|
49
69
|
}
|
|
@@ -57,4 +77,4 @@ function opencodeTextPart(value) {
|
|
|
57
77
|
if (isRecord(part) && part.type === "text") return typeof part.text === "string" ? part.text : void 0;
|
|
58
78
|
}
|
|
59
79
|
//#endregion
|
|
60
|
-
export {
|
|
80
|
+
export { opencodeSdkRuntimeAdapter };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/runtime/opencode-agent-name.ts
|
|
2
|
+
const MOKA_PROFILE_PREFIX = "moka-";
|
|
3
|
+
/**
|
|
4
|
+
* Map a pipeline profile id to the opencode agent name a served instance
|
|
5
|
+
* exposes (e.g. `moka-code-writer` -> `MoKa Code Writer`). Shared by the slash
|
|
6
|
+
* command installer and the SDK runtime so per-message agent selection targets
|
|
7
|
+
* the same `.opencode/agent/*` definition the host projects.
|
|
8
|
+
*/
|
|
9
|
+
function opencodeAgentName(profileId) {
|
|
10
|
+
if (!profileId.startsWith(MOKA_PROFILE_PREFIX)) return profileId;
|
|
11
|
+
return `MoKa ${profileId.slice(5).split("-").map(opencodeAgentNamePart).join(" ")}`;
|
|
12
|
+
}
|
|
13
|
+
function opencodeAgentNamePart(part) {
|
|
14
|
+
if (part === "opencode") return "OpenCode";
|
|
15
|
+
return `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { opencodeAgentName };
|
|
@@ -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,12 +1,12 @@
|
|
|
1
|
+
import { uniqueGeneratedId } from "../../strings.js";
|
|
2
|
+
import { dependentsByNeed, hasReachableDependent } from "../../planning/graph.js";
|
|
3
|
+
import { isCoverageNode, isImplementationNode } from "../scheduling-roles.js";
|
|
1
4
|
//#region src/schedule/passes/coverage.ts
|
|
2
5
|
const DEFAULT_GENERATED_COVERAGE_PROFILE_PREFERENCE = [
|
|
3
6
|
"moka-verifier",
|
|
4
7
|
"moka-acceptance-reviewer",
|
|
5
8
|
"moka-thermo-nuclear-reviewer"
|
|
6
9
|
];
|
|
7
|
-
const GENERATED_ID_INVALID_CHARS_RE = /[^a-z0-9]+/g;
|
|
8
|
-
const GENERATED_ID_TRIM_HYPHENS_RE = /^-+|-+$/g;
|
|
9
|
-
const STARTS_WITH_ALPHA_RE = /^[a-z]/;
|
|
10
10
|
function addGeneratedImplementationCoverage(config, artifact) {
|
|
11
11
|
const coverageProfileId = generatedCoverageProfileId(config);
|
|
12
12
|
if (!coverageProfileId) return artifact;
|
|
@@ -26,8 +26,8 @@ function addNodeScopeImplementationCoverage(config, nodes, coverageProfileId) {
|
|
|
26
26
|
...node,
|
|
27
27
|
nodes: addNodeScopeImplementationCoverage(config, node.nodes, coverageProfileId)
|
|
28
28
|
} : node);
|
|
29
|
-
const
|
|
30
|
-
const uncovered = scopedNodes.filter((node) => isImplementationNode(config, node)).filter((node) => !hasDownstreamCoverage(config, node.id,
|
|
29
|
+
const index = dependentsByNeed(scopedNodes);
|
|
30
|
+
const uncovered = scopedNodes.filter((node) => isImplementationNode(config, node)).filter((node) => !hasDownstreamCoverage(config, node.id, index));
|
|
31
31
|
if (uncovered.length === 0) return scopedNodes;
|
|
32
32
|
const coverageNodeId = uniqueGeneratedId("generated-coverage", new Set(scopedNodes.map((node) => node.id)), "generated-coverage");
|
|
33
33
|
return [...scopedNodes, {
|
|
@@ -82,52 +82,8 @@ function generatedCoverageGates(nodeId) {
|
|
|
82
82
|
}
|
|
83
83
|
];
|
|
84
84
|
}
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
for (const node of nodes) for (const need of node.needs ?? []) {
|
|
88
|
-
const dependents = dependentsByNeed.get(need) ?? [];
|
|
89
|
-
dependents.push(node);
|
|
90
|
-
dependentsByNeed.set(need, dependents);
|
|
91
|
-
}
|
|
92
|
-
return dependentsByNeed;
|
|
93
|
-
}
|
|
94
|
-
function isImplementationNode(config, node) {
|
|
95
|
-
return hasSchedulingRole(config, node, "implementation");
|
|
96
|
-
}
|
|
97
|
-
function hasDownstreamCoverage(config, nodeId, dependentsByNeed) {
|
|
98
|
-
return hasReachableDependent(nodeId, dependentsByNeed, (node) => hasSchedulingRole(config, node, "coverage"));
|
|
99
|
-
}
|
|
100
|
-
function hasSchedulingRole(config, node, role) {
|
|
101
|
-
if (node.kind !== "agent") return false;
|
|
102
|
-
return config.profiles[node.profile]?.scheduling_roles?.includes(role) ?? false;
|
|
103
|
-
}
|
|
104
|
-
function hasReachableDependent(nodeId, dependentsByNeed, matches) {
|
|
105
|
-
const visited = /* @__PURE__ */ new Set();
|
|
106
|
-
const queue = [...dependentsByNeed.get(nodeId) ?? []];
|
|
107
|
-
while (queue.length > 0) {
|
|
108
|
-
const node = queue.shift();
|
|
109
|
-
if (!node || visited.has(node.id)) continue;
|
|
110
|
-
visited.add(node.id);
|
|
111
|
-
if (matches(node)) return true;
|
|
112
|
-
queue.push(...dependentsByNeed.get(node.id) ?? []);
|
|
113
|
-
}
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
function uniqueGeneratedId(value, usedIds, fallbackPrefix) {
|
|
117
|
-
const base = generatedId(value, fallbackPrefix);
|
|
118
|
-
let candidate = base;
|
|
119
|
-
let suffix = 2;
|
|
120
|
-
while (usedIds.has(candidate)) {
|
|
121
|
-
candidate = `${base}-${suffix}`;
|
|
122
|
-
suffix += 1;
|
|
123
|
-
}
|
|
124
|
-
usedIds.add(candidate);
|
|
125
|
-
return candidate;
|
|
126
|
-
}
|
|
127
|
-
function generatedId(value, fallbackPrefix) {
|
|
128
|
-
const slug = value.trim().toLowerCase().replaceAll(GENERATED_ID_INVALID_CHARS_RE, "-").replaceAll(GENERATED_ID_TRIM_HYPHENS_RE, "");
|
|
129
|
-
if (STARTS_WITH_ALPHA_RE.test(slug)) return slug;
|
|
130
|
-
return slug ? `${fallbackPrefix}-${slug}` : fallbackPrefix;
|
|
85
|
+
function hasDownstreamCoverage(config, nodeId, index) {
|
|
86
|
+
return hasReachableDependent(nodeId, index, (node) => isCoverageNode(config, node));
|
|
131
87
|
}
|
|
132
88
|
//#endregion
|
|
133
89
|
export { addGeneratedImplementationCoverage };
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
+
import { uniqueGeneratedId } from "../../strings.js";
|
|
2
|
+
import { flattenNodes } from "../../planning/graph.js";
|
|
1
3
|
//#region src/schedule/passes/ids.ts
|
|
2
|
-
const GENERATED_ID_INVALID_CHARS_RE = /[^a-z0-9]+/g;
|
|
3
|
-
const GENERATED_ID_TRIM_HYPHENS_RE = /^-+|-+$/g;
|
|
4
|
-
const STARTS_WITH_ALPHA_RE = /^[a-z]/;
|
|
5
4
|
function canonicalizeGeneratedScheduleIds(artifact) {
|
|
6
5
|
return {
|
|
7
6
|
...artifact,
|
|
@@ -11,7 +10,7 @@ function canonicalizeGeneratedScheduleIds(artifact) {
|
|
|
11
10
|
function canonicalizeWorkflowNodeIds(workflow) {
|
|
12
11
|
const nodeIdMap = /* @__PURE__ */ new Map();
|
|
13
12
|
const usedNodeIds = /* @__PURE__ */ new Set();
|
|
14
|
-
for (const node of workflow.nodes
|
|
13
|
+
for (const node of flattenNodes(workflow.nodes, (node) => node.kind === "parallel" ? node.nodes : void 0)) nodeIdMap.set(node.id, uniqueGeneratedId(node.id, usedNodeIds, "node"));
|
|
15
14
|
return {
|
|
16
15
|
...workflow,
|
|
17
16
|
nodes: workflow.nodes.map((node) => rewriteGeneratedWorkflowNodeIds(node, nodeIdMap))
|
|
@@ -28,24 +27,5 @@ function rewriteGeneratedWorkflowNodeIds(node, nodeIdMap) {
|
|
|
28
27
|
nodes: rewritten.nodes.map((child) => rewriteGeneratedWorkflowNodeIds(child, nodeIdMap))
|
|
29
28
|
} : rewritten;
|
|
30
29
|
}
|
|
31
|
-
function uniqueGeneratedId(value, usedIds, fallbackPrefix) {
|
|
32
|
-
const base = generatedId(value, fallbackPrefix);
|
|
33
|
-
let candidate = base;
|
|
34
|
-
let suffix = 2;
|
|
35
|
-
while (usedIds.has(candidate)) {
|
|
36
|
-
candidate = `${base}-${suffix}`;
|
|
37
|
-
suffix += 1;
|
|
38
|
-
}
|
|
39
|
-
usedIds.add(candidate);
|
|
40
|
-
return candidate;
|
|
41
|
-
}
|
|
42
|
-
function generatedId(value, fallbackPrefix) {
|
|
43
|
-
const slug = value.trim().toLowerCase().replaceAll(GENERATED_ID_INVALID_CHARS_RE, "-").replaceAll(GENERATED_ID_TRIM_HYPHENS_RE, "");
|
|
44
|
-
if (STARTS_WITH_ALPHA_RE.test(slug)) return slug;
|
|
45
|
-
return slug ? `${fallbackPrefix}-${slug}` : fallbackPrefix;
|
|
46
|
-
}
|
|
47
|
-
function flattenWorkflowNode(node) {
|
|
48
|
-
return node.kind === "parallel" ? [node, ...node.nodes.flatMap(flattenWorkflowNode)] : [node];
|
|
49
|
-
}
|
|
50
30
|
//#endregion
|
|
51
31
|
export { canonicalizeGeneratedScheduleIds };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/schedule/scheduling-roles.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether an agent node's profile declares the given scheduling role. Non-agent
|
|
4
|
+
* nodes never carry scheduling roles. This is the single source of truth for the
|
|
5
|
+
* implementation/coverage role policy used by schedule generation and
|
|
6
|
+
* validation.
|
|
7
|
+
*/
|
|
8
|
+
function hasSchedulingRole(config, node, role) {
|
|
9
|
+
if (node.kind !== "agent") return false;
|
|
10
|
+
return config.profiles[node.profile]?.scheduling_roles?.includes(role) ?? false;
|
|
11
|
+
}
|
|
12
|
+
function isImplementationNode(config, node) {
|
|
13
|
+
return hasSchedulingRole(config, node, "implementation");
|
|
14
|
+
}
|
|
15
|
+
function isCoverageNode(config, node) {
|
|
16
|
+
return hasSchedulingRole(config, node, "coverage");
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { isCoverageNode, isImplementationNode };
|
package/dist/strings.js
CHANGED
|
@@ -4,5 +4,34 @@ function uniqueStrings(values, options = {}) {
|
|
|
4
4
|
const unique = [...new Set(input)];
|
|
5
5
|
return options.sort ? unique.sort() : unique;
|
|
6
6
|
}
|
|
7
|
+
const GENERATED_ID_INVALID_CHARS_RE = /[^a-z0-9]+/g;
|
|
8
|
+
const GENERATED_ID_TRIM_HYPHENS_RE = /^-+|-+$/g;
|
|
9
|
+
const STARTS_WITH_ALPHA_RE = /^[a-z]/;
|
|
10
|
+
/**
|
|
11
|
+
* Slugify an arbitrary string into a workflow-safe id (lowercase, hyphenated).
|
|
12
|
+
* When the slug does not begin with a letter it is prefixed with
|
|
13
|
+
* `fallbackPrefix` so the result is always a valid node/workflow id.
|
|
14
|
+
*/
|
|
15
|
+
function generatedId(value, fallbackPrefix) {
|
|
16
|
+
const slug = value.trim().toLowerCase().replaceAll(GENERATED_ID_INVALID_CHARS_RE, "-").replaceAll(GENERATED_ID_TRIM_HYPHENS_RE, "");
|
|
17
|
+
if (STARTS_WITH_ALPHA_RE.test(slug)) return slug;
|
|
18
|
+
return slug ? `${fallbackPrefix}-${slug}` : fallbackPrefix;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Slugify `value` to a generated id (see {@link generatedId}) and disambiguate
|
|
22
|
+
* against `usedIds` by appending an incrementing numeric suffix. Mutates
|
|
23
|
+
* `usedIds` to reserve the chosen id.
|
|
24
|
+
*/
|
|
25
|
+
function uniqueGeneratedId(value, usedIds, fallbackPrefix) {
|
|
26
|
+
const base = generatedId(value, fallbackPrefix);
|
|
27
|
+
let candidate = base;
|
|
28
|
+
let suffix = 2;
|
|
29
|
+
while (usedIds.has(candidate)) {
|
|
30
|
+
candidate = `${base}-${suffix}`;
|
|
31
|
+
suffix += 1;
|
|
32
|
+
}
|
|
33
|
+
usedIds.add(candidate);
|
|
34
|
+
return candidate;
|
|
35
|
+
}
|
|
7
36
|
//#endregion
|
|
8
|
-
export { uniqueStrings };
|
|
37
|
+
export { uniqueGeneratedId, uniqueStrings };
|
|
@@ -306,14 +306,51 @@ timeouts, output limits, sanitized env, and explicit trust flags.
|
|
|
306
306
|
|
|
307
307
|
## Host Support Matrix
|
|
308
308
|
|
|
309
|
-
| Runner
|
|
310
|
-
|
|
|
311
|
-
| OpenCode
|
|
312
|
-
|
|
|
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.
|
|
316
|
-
|
|
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.
|
|
322
|
+
|
|
323
|
+
## Planning: Compile And Generate
|
|
324
|
+
|
|
325
|
+
Two distinct planning strategies turn config into an executable plan, and the
|
|
326
|
+
module layout names the distinction (`src/planning/`):
|
|
327
|
+
|
|
328
|
+
- `planning/compile.ts` — **deterministic DAG compilation**. `compileWorkflowPlan`
|
|
329
|
+
validates a workflow's nodes (duplicate ids, missing dependencies, group
|
|
330
|
+
references, cycles), normalizes group dependencies, and produces the
|
|
331
|
+
`WorkflowExecutionPlan` (graph, topological order, parallel batches). This is
|
|
332
|
+
the engine's mandatory front door: every execution path runs through it,
|
|
333
|
+
including AI-generated schedules. Exposed as the `./planner` package subpath.
|
|
334
|
+
- `planning/generate.ts` — **optional AI decomposition**. `generateScheduleArtifact`
|
|
335
|
+
drives a planner profile to decompose a task into a schedule artifact, then
|
|
336
|
+
normalizes it through auditable passes (coverage → models → ids → references)
|
|
337
|
+
and finally feeds it back into `compileScheduleArtifact`, which merges the
|
|
338
|
+
generated workflows into config and calls `compileWorkflowPlan`. So generate
|
|
339
|
+
output is always an input to compile — never a substitute for it. Exposed as
|
|
340
|
+
the `./schedule` package subpath.
|
|
341
|
+
|
|
342
|
+
Both layers share one policy-free traversal model, `planning/graph.ts`
|
|
343
|
+
(`flattenNodes`, `dependentsByNeed`, `hasReachableDependent`, `findNode`,
|
|
344
|
+
`findDependencyCycles`). Each caller supplies its own predicates and owns its
|
|
345
|
+
error vocabulary, so config validation, deterministic compile, and AI-schedule
|
|
346
|
+
validation reason over the same graph structure without sharing layer-specific
|
|
347
|
+
policy. Schedule-specific support (artifact re-export barrel, baseline, prompts,
|
|
348
|
+
the normalization passes, backlog context, scheduling-role policy) stays under
|
|
349
|
+
`src/schedule/` and imports the artifact + generate API from `planning/generate`.
|
|
350
|
+
|
|
351
|
+
The toposort uses `@dagrejs/graphlib` for the graph model but an iterative
|
|
352
|
+
traversal for the topological sort, because graphlib's recursive topsort can
|
|
353
|
+
overflow the call stack on deep generated workflow chains.
|
|
317
354
|
|
|
318
355
|
## Troubleshooting
|
|
319
356
|
|