@oisincoveney/pipeline 1.16.2 → 1.18.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 CHANGED
@@ -39,17 +39,16 @@ MCP is the Momokaya remote endpoint
39
39
 
40
40
  The default GitHub MCP registration uses GitHub's official container in
41
41
  read-only mode and reads `GITHUB_PERSONAL_ACCESS_TOKEN` from the environment.
42
- The Momokaya Qdrant endpoint is protected by Traefik HTTP Basic auth. Set
43
- `MEMORY_MCP_BASIC_AUTH` to the base64 `user:password` payload before running
44
- `pipe init` if you want init to register that remote server with MCPM:
42
+ The Momokaya gateway endpoint is protected by Traefik HTTP Basic auth. Set
43
+ `PIPELINE_MCP_GATEWAY_AUTHORIZATION` to the full HTTP `Authorization` header
44
+ value before starting Codex or OpenCode:
45
45
 
46
46
  ```shell
47
- export MEMORY_MCP_BASIC_AUTH="$(printf '%s' 'user:password' | base64)"
47
+ export PIPELINE_MCP_GATEWAY_AUTHORIZATION="Basic $(printf '%s' 'user:password' | base64)"
48
48
  ```
49
49
 
50
- When `MEMORY_MCP_BASIC_AUTH` is not set, `pipe init` still writes the default
51
- scaffold and keeps the generated `qdrant` MCP entry, but skips immediate MCPM
52
- registration for that private endpoint.
50
+ `pipe init` writes project-level Codex and OpenCode config that points at the
51
+ singleton `pipeline-gateway` MCP server.
53
52
 
54
53
  Check local prerequisites and config health:
55
54
 
@@ -0,0 +1,28 @@
1
+ //#region src/commands/pipeline-command.ts
2
+ const BUILTIN_PIPE_COMMANDS = new Set([
3
+ "run",
4
+ "pipe",
5
+ "validate",
6
+ "explain-plan",
7
+ "doctor",
8
+ "init",
9
+ "install-commands",
10
+ "mcp",
11
+ "runner-job"
12
+ ]);
13
+ function registerConfiguredEntrypointCommands(program, config, runEntrypoint) {
14
+ const registered = /* @__PURE__ */ new Set();
15
+ if (!config) return registered;
16
+ const reservedCommands = new Set(program.commands.map((command) => command.name()));
17
+ for (const [id, entrypoint] of Object.entries(config.entrypoints)) {
18
+ if (reservedCommands.has(id)) continue;
19
+ program.command(id).description(entrypoint.description ?? `Run the ${id} workflow`).argument("<description...>", "task description").action(async (descriptionParts) => {
20
+ await runEntrypoint(id, descriptionParts.join(" "));
21
+ });
22
+ registered.add(id);
23
+ reservedCommands.add(id);
24
+ }
25
+ return registered;
26
+ }
27
+ //#endregion
28
+ export { BUILTIN_PIPE_COMMANDS, registerConfiguredEntrypointCommands };
@@ -0,0 +1,10 @@
1
+ import { runRunnerJob } from "../runner-job/run.js";
2
+ //#region src/commands/runner-job-command.ts
3
+ function registerRunnerJobCommand(program) {
4
+ program.command("runner-job").description("Run an in-pod pipeline runner job from the console payload").action(async () => {
5
+ const exitCode = await runRunnerJob();
6
+ process.exitCode = exitCode;
7
+ });
8
+ }
9
+ //#endregion
10
+ export { registerRunnerJobCommand };
package/dist/config.d.ts CHANGED
@@ -239,7 +239,7 @@ declare const configSchema: z.ZodObject<{
239
239
  local: "local";
240
240
  }>;
241
241
  provider: z.ZodLiteral<"toolhive">;
242
- token_env: z.ZodDefault<z.ZodString>;
242
+ authorization_env: z.ZodDefault<z.ZodString>;
243
243
  url: z.ZodOptional<z.ZodString>;
244
244
  url_env: z.ZodDefault<z.ZodString>;
245
245
  }, z.core.$strict>>;
@@ -309,6 +309,20 @@ declare const configSchema: z.ZodObject<{
309
309
  task: "task";
310
310
  }>>>;
311
311
  }, z.core.$strict>>>;
312
+ runner_job: z.ZodDefault<z.ZodObject<{
313
+ environment: z.ZodDefault<z.ZodObject<{
314
+ setup: z.ZodDefault<z.ZodArray<z.ZodObject<{
315
+ args: z.ZodDefault<z.ZodArray<z.ZodString>>;
316
+ command: z.ZodString;
317
+ required: z.ZodDefault<z.ZodBoolean>;
318
+ }, z.core.$strict>>>;
319
+ smoke: z.ZodDefault<z.ZodArray<z.ZodObject<{
320
+ args: z.ZodDefault<z.ZodArray<z.ZodString>>;
321
+ command: z.ZodString;
322
+ required: z.ZodDefault<z.ZodBoolean>;
323
+ }, z.core.$strict>>>;
324
+ }, z.core.$strict>>;
325
+ }, z.core.$strict>>;
312
326
  rules: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
313
327
  path: z.ZodString;
314
328
  }, z.core.$strict>>>;
package/dist/config.js CHANGED
@@ -132,7 +132,7 @@ const mcpGatewaySchema = z.object({
132
132
  default_profile: z.string().min(1).optional(),
133
133
  mode: z.enum(["hosted", "local"]),
134
134
  provider: z.literal("toolhive"),
135
- token_env: z.string().min(1).default("MEMORY_MCP_BASIC_AUTH"),
135
+ authorization_env: z.string().min(1).default("PIPELINE_MCP_GATEWAY_AUTHORIZATION"),
136
136
  url: z.string().url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol), { message: "MCP gateway url must use http or https" }).optional(),
137
137
  url_env: z.string().min(1).default("PIPELINE_MCP_GATEWAY_URL")
138
138
  }).strict();
@@ -361,6 +361,19 @@ const workflowSchema = z.object({
361
361
  execution: workflowExecutionSchema.optional(),
362
362
  nodes: z.array(workflowNodeSchema)
363
363
  }).strict();
364
+ const runnerJobCommandSchema = z.object({
365
+ args: z.array(z.string()).default([]),
366
+ command: z.string().min(1),
367
+ required: z.boolean().default(true)
368
+ }).strict();
369
+ const runnerJobEnvironmentSchema = z.object({
370
+ setup: z.array(runnerJobCommandSchema).default([]),
371
+ smoke: z.array(runnerJobCommandSchema).default([])
372
+ }).strict();
373
+ const runnerJobConfigSchema = z.object({ environment: runnerJobEnvironmentSchema.default({
374
+ setup: [],
375
+ smoke: []
376
+ }) }).strict();
364
377
  const runnersFileSchema = z.object({
365
378
  runners: strictRecord(runnerSchema).default({}),
366
379
  version: z.literal(1)
@@ -381,6 +394,10 @@ const pipelineFileSchema = z.object({
381
394
  on: {}
382
395
  }),
383
396
  orchestrator: orchestratorSchema,
397
+ runner_job: runnerJobConfigSchema.default({ environment: {
398
+ setup: [],
399
+ smoke: []
400
+ } }),
384
401
  schedules: strictRecord(schedulePolicySchema).default({}),
385
402
  task_context: taskContextResolverSchema.optional(),
386
403
  workflows: strictRecord(workflowSchema).default({}),
@@ -397,6 +414,10 @@ const configSchema = z.object({
397
414
  mcp_servers: strictRecord(mcpServerSchema).default({}),
398
415
  orchestrator: orchestratorSchema,
399
416
  profiles: strictRecord(profileSchema).default({}),
417
+ runner_job: runnerJobConfigSchema.default({ environment: {
418
+ setup: [],
419
+ smoke: []
420
+ } }),
400
421
  rules: strictRecord(pathRefSchema).default({}),
401
422
  runners: strictRecord(runnerSchema).default({}),
402
423
  schedules: strictRecord(schedulePolicySchema).default({}),
@@ -519,6 +540,7 @@ function parsePipelineConfigParts(sources, projectRoot, sourcePaths = {
519
540
  mcp_servers: profiles.mcp_servers,
520
541
  orchestrator: pipeline.orchestrator,
521
542
  profiles: profiles.profiles,
543
+ runner_job: pipeline.runner_job,
522
544
  rules: profiles.rules,
523
545
  runners: runners.runners,
524
546
  schedules: pipeline.schedules,
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { PipelineConfigError, loadPipelineConfig, tryLoadPipelineConfig } from "./config.js";
3
- import { compileWorkflowPlan } from "./workflow-planner.js";
4
- import { formatInstallCommandsResult, installCommands, parseCommandHost } from "./install-commands.js";
3
+ import { BUILTIN_PIPE_COMMANDS, registerConfiguredEntrypointCommands } from "./commands/pipeline-command.js";
5
4
  import { configureGatewayHosts, localGatewayStatus, renderGatewayConfig, runGatewayDoctor, startLocalGateway } from "./mcp/gateway.js";
6
5
  import { createOrchestratorLaunchPlan, createRunnerLaunchPlan } from "./runner.js";
6
+ import { compileWorkflowPlan } from "./workflow-planner.js";
7
7
  import { formatConfigError, runPipelineFromConfig } from "./pipeline-runtime.js";
8
- import { runKubernetesRunnerJob } from "./kubernetes-runner.js";
9
- import { formatPipelineInitResult, initPipelineProject } from "./pipeline-init.js";
10
8
  import { compileScheduleArtifact, generateScheduleArtifact, parseScheduleArtifact } from "./schedule-planner.js";
9
+ import { registerRunnerJobCommand } from "./commands/runner-job-command.js";
10
+ import { formatInstallCommandsResult, installCommands, parseCommandHost } from "./install-commands.js";
11
+ import { formatPipelineInitResult, initPipelineProject } from "./pipeline-init.js";
11
12
  import { existsSync, readFileSync, realpathSync } from "node:fs";
12
13
  import { resolve } from "node:path";
13
14
  import { fileURLToPath } from "node:url";
@@ -190,17 +191,6 @@ function truncateMiddle(text, maxLength) {
190
191
  const keep = Math.floor((maxLength - 32) / 2);
191
192
  return `${text.slice(0, keep)}\n... truncated ...\n${text.slice(-keep)}`;
192
193
  }
193
- const BUILTIN_PIPE_COMMANDS = new Set([
194
- "run",
195
- "pipe",
196
- "validate",
197
- "explain-plan",
198
- "doctor",
199
- "init",
200
- "install-commands",
201
- "mcp",
202
- "runner-job"
203
- ]);
204
194
  function createCliProgram() {
205
195
  const configuredPipeline = tryLoadConfiguredEntrypoints(process.env.PIPELINE_TARGET_PATH ?? process.cwd());
206
196
  const program = new Command();
@@ -284,11 +274,8 @@ function createCliProgram() {
284
274
  });
285
275
  console.log(formatInstallCommandsResult(result));
286
276
  });
287
- program.command("runner-job").description("Run an in-pod pipeline runner job from the console payload").action(async () => {
288
- const exitCode = await runKubernetesRunnerJob();
289
- process.exitCode = exitCode;
290
- });
291
- const configuredEntrypointCommands = registerConfiguredEntrypointCommands(program, configuredPipeline);
277
+ registerRunnerJobCommand(program);
278
+ const configuredEntrypointCommands = registerConfiguredEntrypointCommands(program, configuredPipeline, (entrypoint, task) => pipe(task, { entrypoint }));
292
279
  if (configuredEntrypointCommands.size > 0) program.configureHelp({ subcommandTerm(command) {
293
280
  if (configuredEntrypointCommands.has(command.name())) return command.name();
294
281
  return Help.prototype.subcommandTerm.call(this, command);
@@ -303,20 +290,6 @@ function tryLoadConfiguredEntrypoints(cwd) {
303
290
  throw err;
304
291
  }
305
292
  }
306
- function registerConfiguredEntrypointCommands(program, config) {
307
- const registered = /* @__PURE__ */ new Set();
308
- if (!config) return registered;
309
- const reservedCommands = new Set(program.commands.map((command) => command.name()));
310
- for (const [id, entrypoint] of Object.entries(config.entrypoints)) {
311
- if (reservedCommands.has(id)) continue;
312
- program.command(id).description(entrypoint.description ?? `Run the ${id} workflow`).argument("<description...>", "task description").action(async (descriptionParts) => {
313
- await pipe(descriptionParts.join(" "), { entrypoint: id });
314
- });
315
- registered.add(id);
316
- reservedCommands.add(id);
317
- }
318
- return registered;
319
- }
320
293
  function parseGatewayHostScope(value) {
321
294
  if (value === "project" || value === "global") return value;
322
295
  throw new Error("scope must be project or global");
@@ -527,7 +500,6 @@ function formatWorkflowPlanNode(node, config, worktreePath) {
527
500
  `kind=${node.kind}`,
528
501
  `needs=${node.needs.join(",") || "none"}`,
529
502
  launch ? `runner=${launch.runnerId}` : "",
530
- launch ? `strategy=${launch.strategy}` : "",
531
503
  node.gates?.length ? `gates=${node.gates.length}` : "gates=0",
532
504
  node.artifacts?.length ? `artifacts=${node.artifacts.map((artifact) => artifact.path).join(",")}` : "artifacts=none"
533
505
  ].filter(Boolean).join(" ");
@@ -542,14 +514,12 @@ function resolveWorkflowSelection(config, workflowId, entrypointId) {
542
514
  }
543
515
  function formatOrchestratorPlan(config, worktreePath) {
544
516
  const orchestrator = config.profiles[config.orchestrator.profile];
545
- const launch = createOrchestratorLaunchPlan(config, {
546
- nodeId: "orchestrator",
547
- prompt: "<task>",
548
- worktreePath
549
- });
550
517
  return [
551
- `Orchestrator: runner=${launch.runnerId}`,
552
- `strategy=${launch.strategy}`,
518
+ `Orchestrator: runner=${createOrchestratorLaunchPlan(config, {
519
+ nodeId: "orchestrator",
520
+ prompt: "<task>",
521
+ worktreePath
522
+ }).runnerId}`,
553
523
  orchestrator.model ? `model=${orchestrator.model}` : "",
554
524
  formatList("rules", orchestrator.rules),
555
525
  formatList("skills", orchestrator.skills),
@@ -1,5 +1,6 @@
1
1
  import { resolveFileReference } from "./path-refs.js";
2
2
  import { loadPipelineConfig } from "./config.js";
3
+ import { renderCodexGatewayConfig } from "./mcp/gateway.js";
3
4
  import { compileWorkflowPlan } from "./workflow-planner.js";
4
5
  import { existsSync, readFileSync } from "node:fs";
5
6
  import { basename, dirname, join, relative } from "node:path";
@@ -173,6 +174,8 @@ function entrypointDispatchBlock(host, config, id, entrypoint) {
173
174
  `Generate a schedule for entrypoint \`${id}\` and the user task.`,
174
175
  `The schedule policy is \`${entrypoint.schedule}\`.`,
175
176
  `Run \`pipe run --entrypoint ${id} <task description>\` to generate and execute the schedule artifact.`,
177
+ "The pipeline CLI runtime is the deterministic graph scheduler for scheduled entrypoints.",
178
+ "It launches configured Codex/OpenCode agent subprocesses as soon as their dependencies pass.",
176
179
  "Use `pipe run --schedule <schedule.yaml>` only when rerunning an existing schedule artifact."
177
180
  ].join("\n");
178
181
  }
@@ -358,7 +361,7 @@ function codexDefinitions(config, cwd) {
358
361
  })),
359
362
  projectAgentsMdDefinition(cwd, "codex"),
360
363
  ...nativeCodexProfiles.map(([id, profile]) => codexTomlAgentDefinition(config, cwd, id, profile)),
361
- codexProjectConfigAgentDefinition()
364
+ codexProjectConfigAgentDefinition(config)
362
365
  ];
363
366
  }
364
367
  function projectAgentsMdDefinition(cwd, host) {
@@ -406,6 +409,7 @@ function codexTomlAgentDefinition(config, cwd, id, profile) {
406
409
  };
407
410
  const skillConfig = codexSkillConfig(config, cwd, profile);
408
411
  const agentConfig = {
412
+ name: id,
409
413
  description: profile.description ?? id,
410
414
  ...profileWithResolvedModel.model ? { model: profileWithResolvedModel.model } : {},
411
415
  ...profile.filesystem?.mode ? { sandbox_mode: profile.filesystem.mode } : {},
@@ -424,16 +428,12 @@ function codexTomlAgentDefinition(config, cwd, id, profile) {
424
428
  ].join("\n"),
425
429
  skills: { config: skillConfig }
426
430
  };
427
- const mcpConfig = "mcp_servers" in agentConfig ? agentConfig : {
428
- ...agentConfig,
429
- mcp_servers: {}
430
- };
431
431
  return {
432
432
  content: [
433
433
  "# Generated by @oisincoveney/pipeline.",
434
434
  `${OWNER_YAML_MARKER_PREFIX}host=codex`,
435
435
  "",
436
- stringify(mcpConfig).trimEnd(),
436
+ stringify(agentConfig).trimEnd(),
437
437
  ""
438
438
  ].join("\n"),
439
439
  host: "codex",
@@ -470,13 +470,14 @@ function codexSkillConfig(config, cwd, profile) {
470
470
  }];
471
471
  });
472
472
  }
473
- function codexProjectConfigAgentDefinition() {
473
+ function codexProjectConfigAgentDefinition(config) {
474
474
  return {
475
475
  block: {
476
476
  end: CODEX_AGENT_CONFIG_END,
477
477
  start: CODEX_AGENT_CONFIG_START
478
478
  },
479
479
  content: [
480
+ ...config.mcp_gateway ? [renderCodexGatewayConfig(config).trimEnd(), ""] : [],
480
481
  CODEX_AGENT_CONFIG_START,
481
482
  stringify({ agents: { max_depth: 1 } }).trimEnd(),
482
483
  CODEX_AGENT_CONFIG_END,
@@ -5,8 +5,7 @@ import { execa } from "execa";
5
5
  //#region src/mcp/gateway.ts
6
6
  const PIPELINE_GATEWAY_SERVER_ID = "pipeline-gateway";
7
7
  const DEFAULT_LOCAL_GATEWAY_URL = "http://127.0.0.1:4483/mcp";
8
- const AUTHORIZATION_ENV_RE = /\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
9
- const LEGACY_CODEX_MCP_RE = /\[mcp_servers\./;
8
+ const LEGACY_CODEX_MCP_RE = /^\s*\[mcp_servers\.(?!pipeline-gateway(?:\]|\.env_http_headers\]))/m;
10
9
  const LEGACY_OPENCODE_MCP_RE = /"mcp"\s*:\s*{(?!\s*"pipeline-gateway")/s;
11
10
  const LEGACY_PIPELINE_MCP_RE = /path:\s*\.mcp\.json|uvx\s+mcpm|mcpm\s+run/;
12
11
  var PipelineMcpGatewayError = class extends Error {
@@ -48,7 +47,7 @@ function renderGatewayConfig(config) {
48
47
  `mode: ${gateway.mode}`,
49
48
  gateway.url ? `url: ${gateway.url}` : "",
50
49
  `url_env: ${gateway.url_env}`,
51
- `token_env: ${gateway.token_env}`,
50
+ `authorization_env: ${gateway.authorization_env}`,
52
51
  gateway.default_profile ? `default_profile: ${gateway.default_profile}` : "",
53
52
  `resolved_url: ${gatewayUrl(gateway)}`
54
53
  ].filter(Boolean).join("\n");
@@ -57,10 +56,12 @@ function renderCodexGatewayConfig(config) {
57
56
  const gateway = configuredGateway(config);
58
57
  return [
59
58
  "# Generated by @oisincoveney/pipeline.",
60
- "# Codex pipeline runs receive MCP gateway config through runtime launch args.",
61
- "# Persistent Codex MCP config is intentionally left empty to avoid startup-time MCP fan-out.",
62
- `# gateway_url = ${JSON.stringify(gatewayUrl(gateway))}`,
63
- `# token_env = ${JSON.stringify(gateway.token_env)}`,
59
+ "",
60
+ `[mcp_servers.${PIPELINE_GATEWAY_SERVER_ID}]`,
61
+ `url = ${JSON.stringify(gatewayUrl(gateway))}`,
62
+ "",
63
+ `[mcp_servers.${PIPELINE_GATEWAY_SERVER_ID}.env_http_headers]`,
64
+ `Authorization = ${JSON.stringify(gateway.authorization_env)}`,
64
65
  ""
65
66
  ].join("\n");
66
67
  }
@@ -71,6 +72,7 @@ function renderOpenCodeGatewayConfig(config) {
71
72
  mcp: { [PIPELINE_GATEWAY_SERVER_ID]: {
72
73
  enabled: true,
73
74
  headers: gatewayOpenCodeHeaders(gateway),
75
+ oauth: false,
74
76
  type: "remote",
75
77
  url: gatewayUrl(gateway)
76
78
  } }
@@ -164,23 +166,18 @@ function checkGatewayUrl(gateway) {
164
166
  }
165
167
  }
166
168
  function checkGatewayToken(gateway) {
167
- return process.env[gateway.token_env] ? {
168
- detail: gateway.token_env,
169
- name: "gateway-token",
169
+ return process.env[gateway.authorization_env] ? {
170
+ detail: gateway.authorization_env,
171
+ name: "gateway-authorization",
170
172
  passed: true
171
173
  } : {
172
- detail: `Set ${gateway.token_env}.`,
173
- name: "gateway-token",
174
+ detail: `Set ${gateway.authorization_env}.`,
175
+ name: "gateway-authorization",
174
176
  passed: false
175
177
  };
176
178
  }
177
179
  function gatewayAuthorizationHeader(gateway) {
178
- return `Basic {env:${gateway.token_env}}`;
179
- }
180
- function resolveHeaderEnvPlaceholders(value, env = process.env) {
181
- return value.replace(AUTHORIZATION_ENV_RE, (_match, envVar) => {
182
- return env[envVar] || `{env:${envVar}}`;
183
- });
180
+ return `{env:${gateway.authorization_env}}`;
184
181
  }
185
182
  function gatewayOpenCodeHeaders(gateway) {
186
183
  return { Authorization: gatewayAuthorizationHeader(gateway) };
@@ -254,7 +251,7 @@ async function checkGatewayHealth(gateway) {
254
251
  }
255
252
  }
256
253
  async function firstHealthyGatewayResponse(url, gateway) {
257
- const authorization = process.env[gateway.token_env] ? `Basic ${process.env[gateway.token_env]}` : void 0;
254
+ const authorization = process.env[gateway.authorization_env];
258
255
  for (const healthUrl of gatewayHealthUrls(url)) {
259
256
  const response = await fetch(healthUrl, {
260
257
  headers: {
@@ -302,4 +299,4 @@ function legacyContentHit(cwd, path, pattern) {
302
299
  return pattern.test(readFileSync(fullPath, "utf8")) ? path : void 0;
303
300
  }
304
301
  //#endregion
305
- export { configureGatewayHosts, gatewayServerForProfile, localGatewayStatus, renderGatewayConfig, resolveHeaderEnvPlaceholders, runGatewayDoctor, startLocalGateway };
302
+ export { configureGatewayHosts, gatewayServerForProfile, localGatewayStatus, renderCodexGatewayConfig, renderGatewayConfig, runGatewayDoctor, startLocalGateway };
@@ -287,10 +287,11 @@ runners:
287
287
  opencode:
288
288
  type: opencode
289
289
  command: opencode
290
+ model: opencode/deepseek-v4-flash-free
290
291
  capabilities:
291
292
  native_subagents: true
292
293
  rules: true
293
- skills: false
294
+ skills: true
294
295
  mcp_servers: true
295
296
  tools: [read, list, grep, glob, bash, edit, write, task]
296
297
  filesystem: [read-only, workspace-write]
@@ -353,7 +354,7 @@ mcp_gateway:
353
354
  provider: toolhive
354
355
  mode: local
355
356
  url_env: PIPELINE_MCP_GATEWAY_URL
356
- token_env: MEMORY_MCP_BASIC_AUTH
357
+ authorization_env: PIPELINE_MCP_GATEWAY_AUTHORIZATION
357
358
  default_profile: default
358
359
 
359
360
  profiles:
@@ -563,6 +564,106 @@ profiles:
563
564
  repair:
564
565
  enabled: true
565
566
  max_attempts: 1
567
+ pipeline-opencode-researcher:
568
+ runner: opencode
569
+ description: Research the requested task and produce structured findings with OpenCode.
570
+ instructions:
571
+ path: .pipeline/prompts/researcher.md
572
+ rules: [test-first]
573
+ skills: [research, spec, scope]
574
+ mcp_servers: [pipeline-gateway]
575
+ tools: [read, list, grep, glob, bash]
576
+ filesystem:
577
+ mode: read-only
578
+ allow: ["**/*"]
579
+ deny: ["node_modules/**", "dist/**", ".git/**"]
580
+ network:
581
+ mode: inherit
582
+ output:
583
+ format: json_schema
584
+ schema_path: .pipeline/schemas/research.schema.json
585
+ repair:
586
+ enabled: true
587
+ max_attempts: 1
588
+ pipeline-opencode-test-writer:
589
+ runner: opencode
590
+ description: Add focused failing tests for the requested behavior with OpenCode.
591
+ instructions:
592
+ path: .pipeline/prompts/test-writer.md
593
+ rules: [test-first]
594
+ skills: [test]
595
+ mcp_servers: [pipeline-gateway]
596
+ tools: [read, list, grep, glob, bash, edit, write]
597
+ filesystem:
598
+ mode: workspace-write
599
+ allow: ["**/*"]
600
+ deny: ["node_modules/**", "dist/**", ".git/**"]
601
+ network:
602
+ mode: inherit
603
+ output:
604
+ format: text
605
+ pipeline-opencode-code-writer:
606
+ runner: opencode
607
+ scheduling_roles: [implementation]
608
+ description: Implement production code until the failing tests pass with OpenCode.
609
+ instructions:
610
+ path: .pipeline/prompts/code-writer.md
611
+ rules: [test-first]
612
+ skills: [trace, test, fix, library-first-development]
613
+ mcp_servers: [pipeline-gateway]
614
+ tools: [read, list, grep, glob, bash, edit, write]
615
+ filesystem:
616
+ mode: workspace-write
617
+ allow: ["**/*"]
618
+ deny: ["node_modules/**", "dist/**", ".git/**"]
619
+ network:
620
+ mode: inherit
621
+ output:
622
+ format: text
623
+ pipeline-opencode-acceptance-reviewer:
624
+ runner: opencode
625
+ scheduling_roles: [coverage]
626
+ description: Audit the finished change against every acceptance criterion with OpenCode.
627
+ instructions:
628
+ path: .pipeline/prompts/acceptance-reviewer.md
629
+ rules: [verification]
630
+ skills: [critique, doubt]
631
+ mcp_servers: [pipeline-gateway]
632
+ tools: [read, list, grep, glob, bash]
633
+ filesystem:
634
+ mode: read-only
635
+ allow: ["**/*"]
636
+ deny: ["node_modules/**", "dist/**", ".git/**"]
637
+ network:
638
+ mode: inherit
639
+ output:
640
+ format: json_schema
641
+ schema_path: .pipeline/schemas/acceptance.schema.json
642
+ repair:
643
+ enabled: true
644
+ max_attempts: 1
645
+ pipeline-opencode-verifier:
646
+ runner: opencode
647
+ scheduling_roles: [coverage]
648
+ description: Verify checks, implementation fit, and final evidence with OpenCode.
649
+ instructions:
650
+ path: .pipeline/prompts/verifier.md
651
+ rules: [verification]
652
+ skills: [verify, critique, secure, optimize]
653
+ mcp_servers: [pipeline-gateway]
654
+ tools: [read, list, grep, glob, bash]
655
+ filesystem:
656
+ mode: read-only
657
+ allow: ["**/*"]
658
+ deny: ["node_modules/**", "dist/**", ".git/**"]
659
+ network:
660
+ mode: inherit
661
+ output:
662
+ format: json_schema
663
+ schema_path: .pipeline/schemas/verify.schema.json
664
+ repair:
665
+ enabled: true
666
+ max_attempts: 1
566
667
  `;
567
668
  const VERDICT_SCHEMA = z.enum(["PASS", "FAIL"]);
568
669
  const STRING_ARRAY_SCHEMA = z.array(z.string());
@@ -87,6 +87,20 @@ function createRunnerEventSink(options) {
87
87
  workflowId
88
88
  });
89
89
  },
90
+ recordRunnerJobPhase(phase, message, output) {
91
+ queue.push({
92
+ ...nextEnvelope(),
93
+ log: {
94
+ level: "info",
95
+ message,
96
+ output: {
97
+ phase,
98
+ ...output
99
+ }
100
+ },
101
+ type: "runner.job.phase"
102
+ });
103
+ },
90
104
  recordRuntimeEvent,
91
105
  recordSchemaValidationFailure(message, issues, workflowId) {
92
106
  queue.push({
@@ -0,0 +1,57 @@
1
+ import { execa } from "execa";
2
+ //#region src/runner-job/delivery.ts
3
+ const GITHUB_HTTPS_REPOSITORY_RE = /^https:\/\/github\.com\/([^/]+\/[^/.]+)(?:\.git)?$/;
4
+ const GITHUB_SSH_REPOSITORY_RE = /^git@github\.com:([^/]+\/[^/.]+)(?:\.git)?$/;
5
+ const createPullRequest = async (options) => {
6
+ if (!options.payload.delivery.pullRequest) return null;
7
+ const repository = options.payload.repository;
8
+ const env = compactEnv(options.env);
9
+ const runCommand = options.runCommand ?? runDeliveryCommand;
10
+ const branch = (await runCommand("git", ["branch", "--show-current"], {
11
+ cwd: options.worktreePath,
12
+ env,
13
+ stdin: "ignore"
14
+ })).stdout.trim();
15
+ if (!branch) throw new Error("Pull request delivery requires a checked-out branch");
16
+ const headOwner = options.env.PIPELINE_PR_HEAD_OWNER?.trim() || "oisin-bot";
17
+ const repositoryName = githubRepositoryName(repository.url);
18
+ await runCommand("git", [
19
+ "push",
20
+ "--set-upstream",
21
+ "origin",
22
+ branch
23
+ ], {
24
+ cwd: options.worktreePath,
25
+ env,
26
+ stdin: "ignore"
27
+ });
28
+ const url = (await runCommand("gh", [
29
+ "pr",
30
+ "create",
31
+ "--fill",
32
+ "--base",
33
+ repository.baseBranch,
34
+ "--head",
35
+ `${headOwner}:${branch}`,
36
+ "--repo",
37
+ repositoryName
38
+ ], {
39
+ cwd: options.worktreePath,
40
+ env,
41
+ stdin: "ignore"
42
+ })).stdout.trim();
43
+ return url ? { url } : null;
44
+ };
45
+ const runDeliveryCommand = (command, args, options) => execa(command, args, options);
46
+ function compactEnv(env) {
47
+ return Object.fromEntries(Object.entries(env).filter((entry) => Boolean(entry[1])));
48
+ }
49
+ function githubRepositoryName(repositoryUrl) {
50
+ const httpsMatch = repositoryUrl.match(GITHUB_HTTPS_REPOSITORY_RE);
51
+ if (httpsMatch?.[1]) return httpsMatch[1];
52
+ const sshMatch = repositoryUrl.match(GITHUB_SSH_REPOSITORY_RE);
53
+ if (sshMatch?.[1]) return sshMatch[1];
54
+ throw new Error("Pull request delivery requires a GitHub repository URL");
55
+ }
56
+ //#endregion
57
+ export { createPullRequest };
@@ -0,0 +1,34 @@
1
+ import { loadPipelineConfig } from "../config.js";
2
+ import { execa } from "execa";
3
+ //#region src/runner-job/devspace.ts
4
+ function assertRunnerDevspaceReady(worktreePath) {
5
+ return { config: loadPipelineConfig(worktreePath, { allowMissingLintFileReferences: true }) };
6
+ }
7
+ async function runRunnerDevspaceSmoke(options) {
8
+ const smoke = options.config.runner_job.environment.smoke;
9
+ if (smoke.length === 0) return "skipped";
10
+ const runCommand = options.runCommand ?? runDevspaceCommand;
11
+ for (const command of smoke) await runCommand(command.command, command.args, {
12
+ cwd: options.worktreePath,
13
+ env: compactEnv(options.env),
14
+ stdin: "ignore"
15
+ });
16
+ return "ran";
17
+ }
18
+ async function runRunnerEnvironmentSetup(options) {
19
+ const setup = options.config.runner_job.environment.setup;
20
+ if (setup.length === 0) return "skipped";
21
+ const runCommand = options.runCommand ?? runDevspaceCommand;
22
+ for (const command of setup) await runCommand(command.command, command.args, {
23
+ cwd: options.worktreePath,
24
+ env: compactEnv(options.env),
25
+ stdin: "ignore"
26
+ });
27
+ return "ran";
28
+ }
29
+ const runDevspaceCommand = (command, args, options) => execa(command, args, options);
30
+ function compactEnv(env) {
31
+ return Object.fromEntries(Object.entries(env).filter((entry) => Boolean(entry[1])));
32
+ }
33
+ //#endregion
34
+ export { assertRunnerDevspaceReady, runRunnerDevspaceSmoke, runRunnerEnvironmentSetup };