@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.
Files changed (64) hide show
  1. package/.agents/skills/orchestrate/SKILL.md +80 -0
  2. package/defaults/pipeline.yaml +111 -0
  3. package/defaults/profiles.yaml +215 -0
  4. package/defaults/runners.yaml +24 -0
  5. package/dist/argo-graph.js +59 -27
  6. package/dist/argo-submit.d.ts +0 -1
  7. package/dist/argo-submit.js +11 -6
  8. package/dist/argo-workflow.d.ts +1 -2
  9. package/dist/argo-workflow.js +0 -2
  10. package/dist/claude-settings-config.js +44 -0
  11. package/dist/cli/program.js +12 -5
  12. package/dist/cli/submit-options.js +1 -2
  13. package/dist/cluster-doctor.js +0 -12
  14. package/dist/commands/pipeline-command.js +1 -1
  15. package/dist/config/defaults.js +7 -350
  16. package/dist/config/schemas.d.ts +9 -9
  17. package/dist/install-commands/claude-code.js +160 -0
  18. package/dist/install-commands/opencode.js +56 -39
  19. package/dist/install-commands/shared.js +32 -5
  20. package/dist/install-commands.js +26 -15
  21. package/dist/json-config-merge.js +47 -0
  22. package/dist/mcp/gateway.js +9 -1
  23. package/dist/moka-global-config.d.ts +0 -1
  24. package/dist/moka-global-config.js +0 -1
  25. package/dist/moka-submit.d.ts +8 -11
  26. package/dist/moka-submit.js +1 -4
  27. package/dist/opencode-project-config.js +2 -45
  28. package/dist/pipeline-runtime.d.ts +9 -0
  29. package/dist/pipeline-runtime.js +50 -8
  30. package/dist/planned-node.js +2 -5
  31. package/dist/{workflow-planner.d.ts → planning/compile.d.ts} +2 -2
  32. package/dist/{workflow-planner.js → planning/compile.js} +6 -83
  33. package/dist/{schedule/planner.d.ts → planning/generate.d.ts} +17 -3
  34. package/dist/{schedule/planner.js → planning/generate.js} +24 -56
  35. package/dist/planning/graph.js +138 -0
  36. package/dist/runner-command/lifecycle-context.js +2 -3
  37. package/dist/runner-command/run.js +2 -3
  38. package/dist/runner-command-contract.d.ts +2 -2
  39. package/dist/runner-event-schema.d.ts +349 -0
  40. package/dist/runner-event-schema.js +185 -0
  41. package/dist/runner-output.js +2 -2
  42. package/dist/runner.d.ts +29 -0
  43. package/dist/runtime/agent-node/agent-node.js +1 -0
  44. package/dist/runtime/context/context.js +1 -1
  45. package/dist/runtime/contracts/contracts.d.ts +18 -1
  46. package/dist/runtime/node-state-store.js +7 -0
  47. package/dist/runtime/opencode-adapter.js +28 -8
  48. package/dist/runtime/opencode-agent-name.js +18 -0
  49. package/dist/runtime/opencode-runtime.js +62 -0
  50. package/dist/runtime/opencode-server.js +67 -0
  51. package/dist/runtime/opencode-session-executor.js +206 -0
  52. package/dist/schedule/passes/coverage.js +7 -51
  53. package/dist/schedule/passes/ids.js +3 -23
  54. package/dist/schedule/scheduling-roles.js +19 -0
  55. package/dist/strings.js +30 -1
  56. package/docs/adr-opencode-first-goal-loop-runtime.md +1 -1
  57. package/docs/config-architecture.md +43 -6
  58. package/docs/mcp-gateway.md +4 -4
  59. package/docs/operator-guide.md +9 -8
  60. package/docs/pipeline-console-runner-contract.md +3 -4
  61. package/docs/slash-command-adapter-contract.md +1 -0
  62. package/package.json +10 -5
  63. package/dist/schedule-planner.d.ts +0 -2
  64. 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
- const opencodeCliRuntimeAdapter = {
5
- continuation() {
6
- return Promise.reject(/* @__PURE__ */ new Error("OpenCode CLI runtime adapter does not expose native continuation yet"));
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-cli-subprocess",
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: "unavailable",
60
+ continuationApi: "session-reuse",
41
61
  nodeId: plan.nodeId,
42
62
  outputFormat: plan.outputFormat,
43
- pluginEvents: "project-local",
63
+ pluginEvents: "server-event-stream",
44
64
  profileId: plan.profileId,
45
65
  runnerId: plan.runnerId,
46
- sessionInspectionApi: "unavailable",
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 { opencodeCliRuntimeAdapter };
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 dependentsByNeed = workflowDependentsByNeed(scopedNodes);
30
- const uncovered = scopedNodes.filter((node) => isImplementationNode(config, node)).filter((node) => !hasDownstreamCoverage(config, node.id, dependentsByNeed));
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 workflowDependentsByNeed(nodes) {
86
- const dependentsByNeed = /* @__PURE__ */ new Map();
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.flatMap(flattenWorkflowNode)) nodeIdMap.set(node.id, uniqueGeneratedId(node.id, usedNodeIds, "node"));
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 };
@@ -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,51 @@ 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.
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