@oisincoveney/pipeline 1.10.2 → 1.10.3

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
@@ -72,10 +72,17 @@ Inspect the execution plan before running:
72
72
  pipe explain-plan
73
73
  ```
74
74
 
75
- Run the default workflow:
75
+ Generate the default schedule artifact:
76
76
 
77
77
  ```shell
78
- pipe run "Implement PIPE-123 user-facing behavior"
78
+ pipe "Implement PIPE-123 user-facing behavior"
79
+ ```
80
+
81
+ This writes `.pipeline/runs/<runId>/schedule.yaml` and stops for approval. Run
82
+ the approved artifact explicitly:
83
+
84
+ ```shell
85
+ pipe run --schedule .pipeline/runs/<runId>/schedule.yaml "Implement PIPE-123 user-facing behavior"
79
86
  ```
80
87
 
81
88
  Run a read-only repository inspection:
@@ -96,9 +103,9 @@ Run an epic drain workflow:
96
103
  pipe epic PIPE-31
97
104
  ```
98
105
 
99
- The `epic` entrypoint routes an epic's child tickets into fixed specialist
100
- tracks, runs those tracks in parallel, merges passing branches, and then runs a
101
- thermo-nuclear code quality review.
106
+ The `epic` entrypoint generates a specialized schedule for the epic, writes the
107
+ schedule artifact, and stops. Execution uses the same `pipe run --schedule`
108
+ approval boundary as `pipe`.
102
109
 
103
110
  The `pipe` binary also accepts the task directly:
104
111
 
@@ -201,8 +208,9 @@ workflows:
201
208
  ```
202
209
 
203
210
  Projects can also declare `entrypoints` in `.pipeline/pipeline.yaml` to expose
204
- stable app or CLI names that resolve to workflows. Direct `--workflow` selection
205
- remains available and takes precedence over `--entrypoint` when both are set.
211
+ stable app or CLI names that resolve to workflows or schedule policies. Direct
212
+ `--workflow` selection remains available and takes precedence over `--entrypoint`
213
+ when both are set.
206
214
 
207
215
  The default scaffold includes a full research, red, green, verify, learn
208
216
  workflow. See `docs/config-architecture.md` for a complete example and the host
@@ -347,7 +355,7 @@ runners:
347
355
 
348
356
  ## App-Facing API
349
357
 
350
- External apps can import the stable config, planner, and runtime surfaces
358
+ External apps can import the stable config, planner, schedule, and runtime surfaces
351
359
  without deep-importing private source paths:
352
360
 
353
361
  ```ts
@@ -356,6 +364,10 @@ import {
356
364
  parsePipelineConfigParts,
357
365
  } from "@oisincoveney/pipeline/config";
358
366
  import { compileWorkflowPlan } from "@oisincoveney/pipeline/planner";
367
+ import {
368
+ compileScheduleArtifact,
369
+ parseScheduleArtifact,
370
+ } from "@oisincoveney/pipeline/schedule";
359
371
  import {
360
372
  runPipelineFromConfig,
361
373
  type PipelineRuntimeResult,
package/dist/config.d.ts CHANGED
@@ -8,6 +8,7 @@ declare const RUNNER_TYPES: readonly ["codex", "opencode", "command"];
8
8
  declare const NODE_KINDS: readonly ["agent", "command", "builtin", "group", "parallel", "workflow"];
9
9
  declare const HOOK_EVENTS: readonly ["workflow.start", "workflow.success", "workflow.failure", "workflow.complete", "node.start", "node.success", "node.error", "gate.failure"];
10
10
  declare const GATE_KINDS: readonly ["acceptance", "artifact", "builtin", "changed_files", "command", "json_schema", "verdict"];
11
+ declare const SCHEDULE_BASELINES: readonly ["epic", "pipe"];
11
12
  type PipelineConfigErrorCode = "PIPELINE_CONFIG_LEGACY_UNSUPPORTED" | "PIPELINE_CONFIG_MISSING" | "PIPELINE_CONFIG_PARSE_ERROR" | "PIPELINE_CONFIG_VALIDATION_ERROR";
12
13
  interface PipelineConfigIssue {
13
14
  message: string;
@@ -129,15 +130,31 @@ type ChildWorkflowNode = WorkflowNodeBase & {
129
130
  worktree_root?: string;
130
131
  };
131
132
  type WorkflowNode = AgentWorkflowNode | CommandWorkflowNode | BuiltinWorkflowNode | GroupWorkflowNode | ParallelWorkflowNode | ChildWorkflowNode;
133
+ declare const workflowSchema: z.ZodObject<{
134
+ description: z.ZodOptional<z.ZodString>;
135
+ execution: z.ZodOptional<z.ZodObject<{
136
+ fail_fast: z.ZodOptional<z.ZodBoolean>;
137
+ max_parallel_nodes: z.ZodOptional<z.ZodNumber>;
138
+ timeout_ms: z.ZodOptional<z.ZodNumber>;
139
+ }, z.core.$strict>>;
140
+ hooks: z.ZodOptional<z.ZodArray<z.ZodString>>;
141
+ nodes: z.ZodArray<z.ZodType<WorkflowNode, unknown, z.core.$ZodTypeInternals<WorkflowNode, unknown>>>;
142
+ }, z.core.$strict>;
132
143
  declare const configSchema: z.ZodObject<{
133
144
  default_workflow: z.ZodString;
134
- entrypoints: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
145
+ entrypoints: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
135
146
  description: z.ZodOptional<z.ZodString>;
136
147
  task_context: z.ZodOptional<z.ZodObject<{
137
148
  type: z.ZodString;
138
149
  }, z.core.$loose>>;
139
150
  workflow: z.ZodString;
140
- }, z.core.$strict>>>;
151
+ }, z.core.$strict>, z.ZodObject<{
152
+ description: z.ZodOptional<z.ZodString>;
153
+ task_context: z.ZodOptional<z.ZodObject<{
154
+ type: z.ZodString;
155
+ }, z.core.$loose>>;
156
+ schedule: z.ZodString;
157
+ }, z.core.$strict>]>>>;
141
158
  hooks: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
142
159
  builtin: z.ZodOptional<z.ZodString>;
143
160
  command: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -205,10 +222,10 @@ declare const configSchema: z.ZodObject<{
205
222
  }, z.core.$strict>>;
206
223
  output: z.ZodOptional<z.ZodObject<{
207
224
  format: z.ZodEnum<{
225
+ json_schema: "json_schema";
208
226
  text: "text";
209
227
  json: "json";
210
228
  jsonl: "jsonl";
211
- json_schema: "json_schema";
212
229
  }>;
213
230
  repair: z.ZodOptional<z.ZodObject<{
214
231
  enabled: z.ZodOptional<z.ZodBoolean>;
@@ -221,6 +238,7 @@ declare const configSchema: z.ZodObject<{
221
238
  runner: z.ZodString;
222
239
  skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
223
240
  tools: z.ZodOptional<z.ZodArray<z.ZodEnum<{
241
+ task: "task";
224
242
  read: "read";
225
243
  list: "list";
226
244
  grep: "grep";
@@ -228,7 +246,6 @@ declare const configSchema: z.ZodObject<{
228
246
  bash: "bash";
229
247
  edit: "edit";
230
248
  write: "write";
231
- task: "task";
232
249
  }>>>;
233
250
  }, z.core.$strict>>>;
234
251
  rules: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -248,14 +265,15 @@ declare const configSchema: z.ZodObject<{
248
265
  disabled: "disabled";
249
266
  }>>>;
250
267
  output_formats: z.ZodOptional<z.ZodArray<z.ZodEnum<{
268
+ json_schema: "json_schema";
251
269
  text: "text";
252
270
  json: "json";
253
271
  jsonl: "jsonl";
254
- json_schema: "json_schema";
255
272
  }>>>;
256
273
  rules: z.ZodOptional<z.ZodBoolean>;
257
274
  skills: z.ZodOptional<z.ZodBoolean>;
258
275
  tools: z.ZodOptional<z.ZodArray<z.ZodEnum<{
276
+ task: "task";
259
277
  read: "read";
260
278
  list: "list";
261
279
  grep: "grep";
@@ -263,7 +281,6 @@ declare const configSchema: z.ZodObject<{
263
281
  bash: "bash";
264
282
  edit: "edit";
265
283
  write: "write";
266
- task: "task";
267
284
  }>>>;
268
285
  }, z.core.$strict>;
269
286
  command: z.ZodOptional<z.ZodString>;
@@ -275,6 +292,15 @@ declare const configSchema: z.ZodObject<{
275
292
  opencode: "opencode";
276
293
  }>;
277
294
  }, z.core.$strict>>>;
295
+ schedules: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
296
+ description: z.ZodOptional<z.ZodString>;
297
+ baseline: z.ZodEnum<{
298
+ pipe: "pipe";
299
+ epic: "epic";
300
+ }>;
301
+ max_parallel_nodes: z.ZodOptional<z.ZodNumber>;
302
+ planner_profile: z.ZodOptional<z.ZodString>;
303
+ }, z.core.$strict>>>;
278
304
  skills: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
279
305
  path: z.ZodString;
280
306
  }, z.core.$strict>>>;
@@ -298,6 +324,7 @@ type RunnerType = (typeof RUNNER_TYPES)[number];
298
324
  type WorkflowNodeKind = (typeof NODE_KINDS)[number];
299
325
  type HookEvent = (typeof HOOK_EVENTS)[number];
300
326
  type GateKind = (typeof GATE_KINDS)[number];
327
+ type ScheduleBaseline = (typeof SCHEDULE_BASELINES)[number];
301
328
  interface PipelineConfigParts {
302
329
  pipeline: string;
303
330
  profiles: string;
@@ -310,6 +337,6 @@ declare function loadPipelineConfig(projectRoot: string, options?: PipelineConfi
310
337
  declare function tryLoadPipelineConfig(projectRoot: string, options?: PipelineConfigValidationOptions): PipelineConfig | null;
311
338
  declare function parsePipelineConfigYaml(source: string, sourcePath?: string, projectRoot?: string): PipelineConfig;
312
339
  declare function parsePipelineConfigParts(sources: PipelineConfigParts, projectRoot?: string, sourcePaths?: PipelineConfigParts, options?: PipelineConfigValidationOptions): PipelineConfig;
313
- declare function validatePipelineConfig(config: PipelineConfig, projectRoot?: string, options?: PipelineConfigValidationOptions): PipelineConfig;
340
+ declare function validatePipelineConfig(rawConfig: PipelineConfig, projectRoot?: string, options?: PipelineConfigValidationOptions): PipelineConfig;
314
341
  //#endregion
315
- export { GateKind, HookEvent, PIPELINE_CONFIG_PATH, PROFILES_CONFIG_PATH, PipelineConfig, PipelineConfigError, PipelineConfigErrorCode, PipelineConfigIssue, PipelineConfigParts, PipelineConfigValidationOptions, RUNNERS_CONFIG_PATH, RunnerType, WorkflowNodeKind, loadPipelineConfig, parsePipelineConfigParts, parsePipelineConfigYaml, tryLoadPipelineConfig, validatePipelineConfig };
342
+ export { GateKind, HookEvent, PIPELINE_CONFIG_PATH, PROFILES_CONFIG_PATH, PipelineConfig, PipelineConfigError, PipelineConfigErrorCode, PipelineConfigIssue, PipelineConfigParts, PipelineConfigValidationOptions, RUNNERS_CONFIG_PATH, RunnerType, ScheduleBaseline, WorkflowNodeKind, loadPipelineConfig, parsePipelineConfigParts, parsePipelineConfigYaml, tryLoadPipelineConfig, validatePipelineConfig, workflowSchema };
package/dist/config.js CHANGED
@@ -54,6 +54,7 @@ const RETRY_REASONS = [
54
54
  "gate_failure",
55
55
  "timeout"
56
56
  ];
57
+ const SCHEDULE_BASELINES = ["epic", "pipe"];
57
58
  var PipelineConfigError = class extends Error {
58
59
  code;
59
60
  issues;
@@ -258,10 +259,16 @@ const hookSchema = z.object({
258
259
  trusted: z.boolean().optional()
259
260
  }).strict();
260
261
  const taskContextResolverSchema = z.object({ type: z.string().min(1) }).passthrough();
261
- const entrypointSchema = z.object({
262
+ const entrypointBaseSchema = z.object({
262
263
  description: z.string().optional(),
263
- task_context: taskContextResolverSchema.optional(),
264
- workflow: z.string()
264
+ task_context: taskContextResolverSchema.optional()
265
+ });
266
+ const entrypointSchema = z.union([entrypointBaseSchema.extend({ workflow: z.string() }).strict(), entrypointBaseSchema.extend({ schedule: z.string() }).strict()]);
267
+ const schedulePolicySchema = z.object({
268
+ description: z.string().optional(),
269
+ baseline: z.enum(SCHEDULE_BASELINES),
270
+ max_parallel_nodes: z.number().int().positive().optional(),
271
+ planner_profile: z.string().optional()
265
272
  }).strict();
266
273
  const workflowNodeBaseSchema = z.object({
267
274
  artifacts: z.array(artifactSchema).optional(),
@@ -321,11 +328,12 @@ const pipelineFileSchema = z.object({
321
328
  entrypoints: strictRecord(entrypointSchema).default({}),
322
329
  hooks: strictRecord(hookSchema).default({}),
323
330
  orchestrator: orchestratorSchema,
331
+ schedules: strictRecord(schedulePolicySchema).default({}),
324
332
  task_context: taskContextResolverSchema.optional(),
325
333
  workflows: strictRecord(workflowSchema).default({}),
326
334
  version: z.literal(1)
327
335
  }).strict();
328
- z.object({
336
+ const configSchema = z.object({
329
337
  default_workflow: z.string(),
330
338
  entrypoints: strictRecord(entrypointSchema).default({}),
331
339
  hooks: strictRecord(hookSchema).default({}),
@@ -334,11 +342,70 @@ z.object({
334
342
  profiles: strictRecord(profileSchema).default({}),
335
343
  rules: strictRecord(pathRefSchema).default({}),
336
344
  runners: strictRecord(runnerSchema).default({}),
345
+ schedules: strictRecord(schedulePolicySchema).default({}),
337
346
  skills: strictRecord(pathRefSchema).default({}),
338
347
  task_context: taskContextResolverSchema.optional(),
339
348
  version: z.literal(1),
340
349
  workflows: strictRecord(workflowSchema).default({})
341
- }).strict();
350
+ }).strict().superRefine(validateConfigReferences);
351
+ function validateConfigReferences(config, ctx) {
352
+ addConfigSchemaIssues(ctx, configReferenceIssues(config));
353
+ }
354
+ function configReferenceIssues(config) {
355
+ return [
356
+ ...missingRegistryReferenceIssue({
357
+ message: (_field, value) => `default workflow '${value}' is not declared`,
358
+ path: ["default_workflow"],
359
+ registry: config.workflows,
360
+ value: config.default_workflow
361
+ }),
362
+ ...registryReferenceIssues("entrypoints", config.entrypoints, [{
363
+ field: "workflow",
364
+ message: (entrypointId, value) => `entrypoint '${entrypointId}' references missing workflow '${value}'`,
365
+ read: (entrypoint) => "workflow" in entrypoint ? entrypoint.workflow : void 0,
366
+ registry: config.workflows
367
+ }, {
368
+ field: "schedule",
369
+ message: (entrypointId, value) => `entrypoint '${entrypointId}' references missing schedule '${value}'`,
370
+ read: (entrypoint) => "schedule" in entrypoint ? entrypoint.schedule : void 0,
371
+ registry: config.schedules
372
+ }]),
373
+ ...registryReferenceIssues("schedules", config.schedules, [{
374
+ field: "planner_profile",
375
+ message: (scheduleId, value) => `schedule '${scheduleId}' references missing planner profile '${value}'`,
376
+ read: (schedule) => schedule.planner_profile,
377
+ registry: config.profiles
378
+ }])
379
+ ];
380
+ }
381
+ function registryReferenceIssues(registryPath, records, rules) {
382
+ return Object.entries(records).flatMap(([recordId, record]) => rules.flatMap((rule) => missingRegistryReferenceIssue({
383
+ message: (_field, value) => rule.message(recordId, value),
384
+ path: [
385
+ registryPath,
386
+ recordId,
387
+ rule.field
388
+ ],
389
+ registry: rule.registry,
390
+ value: rule.read(record)
391
+ })));
392
+ }
393
+ function missingRegistryReferenceIssue({ message, path, registry, value }) {
394
+ return value && !Object.hasOwn(registry, value) ? [{
395
+ message: message(String(path.at(-1)), value),
396
+ path
397
+ }] : [];
398
+ }
399
+ function addConfigSchemaIssues(ctx, issues) {
400
+ for (const issue of issues) addConfigSchemaIssue(ctx, issue.path, issue.message);
401
+ }
402
+ function addConfigSchemaIssue(ctx, path, message) {
403
+ ctx.addIssue({
404
+ code: "custom",
405
+ path,
406
+ message
407
+ });
408
+ }
342
409
  function loadPipelineConfig(projectRoot, options = {}) {
343
410
  const missing = [
344
411
  PIPELINE_CONFIG_PATH,
@@ -397,6 +464,7 @@ function parsePipelineConfigParts(sources, projectRoot, sourcePaths = {
397
464
  profiles: profiles.profiles,
398
465
  rules: profiles.rules,
399
466
  runners: runners.runners,
467
+ schedules: pipeline.schedules,
400
468
  skills: profiles.skills,
401
469
  ...pipeline.task_context ? { task_context: pipeline.task_context } : {},
402
470
  version: 1,
@@ -474,7 +542,13 @@ function normalizeMcpJsonServer(server) {
474
542
  ...server.bearer_token_env_var ? { bearer_token_env_var: server.bearer_token_env_var } : {}
475
543
  };
476
544
  }
477
- function validatePipelineConfig(config, projectRoot, options = {}) {
545
+ function validatePipelineConfig(rawConfig, projectRoot, options = {}) {
546
+ const parsed = configSchema.safeParse(rawConfig);
547
+ if (!parsed.success) throw validationError(parsed.error.issues.map((issue) => ({
548
+ path: issue.path.join("."),
549
+ message: issue.message
550
+ })));
551
+ const config = parsed.data;
478
552
  const issues = [];
479
553
  validateRegistryIds("runners", config.runners, issues);
480
554
  validateRegistryIds("profiles", config.profiles, issues);
@@ -484,14 +558,6 @@ function validatePipelineConfig(config, projectRoot, options = {}) {
484
558
  validateRegistryIds("hooks", config.hooks, issues);
485
559
  validateRegistryIds("workflows", config.workflows, issues);
486
560
  validateRegistryIds("entrypoints", config.entrypoints, issues);
487
- if (!config.workflows[config.default_workflow]) issues.push({
488
- path: "default_workflow",
489
- message: `default workflow '${config.default_workflow}' is not declared`
490
- });
491
- for (const [entrypointId, entrypoint] of Object.entries(config.entrypoints)) if (!config.workflows[entrypoint.workflow]) issues.push({
492
- path: `entrypoints.${entrypointId}.workflow`,
493
- message: `entrypoint '${entrypointId}' references missing workflow '${entrypoint.workflow}'`
494
- });
495
561
  if (config.profiles[config.orchestrator.profile]) validateReferences("orchestrator.hooks", config.orchestrator.hooks, config.hooks, "hook", issues);
496
562
  else issues.push({
497
563
  path: "orchestrator.profile",
@@ -672,4 +738,4 @@ function validationError(issues) {
672
738
  return new PipelineConfigError("PIPELINE_CONFIG_VALIDATION_ERROR", ["Invalid pipeline config:", ...issues.map((issue) => issue.path ? `- ${issue.path}: ${issue.message}` : `- ${issue.message}`)].join("\n"), issues);
673
739
  }
674
740
  //#endregion
675
- export { PIPELINE_CONFIG_PATH, PROFILES_CONFIG_PATH, PipelineConfigError, RUNNERS_CONFIG_PATH, loadPipelineConfig, parsePipelineConfigParts, parsePipelineConfigYaml, tryLoadPipelineConfig, validatePipelineConfig };
741
+ export { PIPELINE_CONFIG_PATH, PROFILES_CONFIG_PATH, PipelineConfigError, RUNNERS_CONFIG_PATH, loadPipelineConfig, parsePipelineConfigParts, parsePipelineConfigYaml, tryLoadPipelineConfig, validatePipelineConfig, workflowSchema };
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ import { Command } from "commander";
5
5
  interface PipeOptions {
6
6
  entrypoint?: string;
7
7
  pipelineRunner?: typeof runPipelineFromConfig;
8
+ schedule?: string;
8
9
  workflow?: string;
9
10
  }
10
11
  /**
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ import { formatConfigError, runPipelineFromConfig } from "./pipeline-runtime.js"
7
7
  import { runKubernetesRunnerJob } from "./kubernetes-runner.js";
8
8
  import { DEFAULT_MCPM_ARGS } from "./mcp/bootstrap.js";
9
9
  import { formatPipelineInitResult, initPipelineProject } from "./pipeline-init.js";
10
- import { existsSync, realpathSync } from "node:fs";
10
+ import { compileScheduleArtifact, generateScheduleArtifact, parseScheduleArtifact } from "./schedule-planner.js";
11
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
11
12
  import { resolve } from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
  import { Command, CommanderError, Help, Option } from "commander";
@@ -26,6 +27,7 @@ function pipe(description, options = {}) {
26
27
  return runConfiguredPipeline({
27
28
  pipelineRunner: options.pipelineRunner ?? runPipelineFromConfig,
28
29
  entrypoint: options.entrypoint,
30
+ schedule: options.schedule,
29
31
  task: description,
30
32
  workflow: options.workflow,
31
33
  worktreePath
@@ -35,7 +37,35 @@ function pipe(description, options = {}) {
35
37
  }
36
38
  }
37
39
  async function runConfiguredPipeline(inputs) {
40
+ const config = loadPipelineConfig(inputs.worktreePath, { allowMissingLintFileReferences: true });
41
+ if (inputs.schedule) {
42
+ const compiled = compileScheduleArtifact(config, parseScheduleArtifact(readFileSync(inputs.schedule, "utf8"), inputs.schedule), inputs.worktreePath);
43
+ await runAndPrintPipeline({
44
+ ...inputs,
45
+ config: compiled.config,
46
+ workflow: compiled.workflowId
47
+ });
48
+ return;
49
+ }
50
+ const scheduledEntrypoint = scheduledEntrypointId(config, inputs.workflow, inputs.entrypoint);
51
+ if (scheduledEntrypoint) {
52
+ const result = await generateScheduleArtifact({
53
+ config,
54
+ entrypointId: scheduledEntrypoint,
55
+ task: inputs.task,
56
+ worktreePath: inputs.worktreePath
57
+ });
58
+ console.log([`Schedule generated: ${result.path}`, `Run after approval: pipe run --schedule ${result.path}`].join("\n"));
59
+ return;
60
+ }
61
+ await runAndPrintPipeline({
62
+ ...inputs,
63
+ config
64
+ });
65
+ }
66
+ async function runAndPrintPipeline(inputs) {
38
67
  const result = await (inputs.pipelineRunner ?? runPipelineFromConfig)({
68
+ config: inputs.config,
39
69
  reporter: formatRuntimeProgress,
40
70
  entrypoint: inputs.entrypoint,
41
71
  task: inputs.task,
@@ -45,6 +75,12 @@ async function runConfiguredPipeline(inputs) {
45
75
  console.log(formatRuntimeResult(result));
46
76
  if (result.outcome !== "PASS") throw new Error(formatRuntimeFailure(result));
47
77
  }
78
+ function scheduledEntrypointId(config, workflowId, entrypointId) {
79
+ if (workflowId) return null;
80
+ const id = entrypointId ?? "pipe";
81
+ const entrypoint = config.entrypoints[id];
82
+ return entrypoint && "schedule" in entrypoint ? id : null;
83
+ }
48
84
  function formatRuntimeProgress(event) {
49
85
  const message = formatRuntimeProgressMessage(event);
50
86
  console.error(message);
@@ -164,24 +200,25 @@ function createCliProgram() {
164
200
  const runAction = async (descriptionParts, flags) => {
165
201
  await pipe(descriptionParts.join(" "), {
166
202
  entrypoint: flags.entrypoint,
203
+ schedule: flags.schedule,
167
204
  workflow: flags.workflow
168
205
  });
169
206
  };
170
- program.command("run").description("Run a workflow from .pipeline/pipeline.yaml").argument("<description...>", "task description").option("--entrypoint <entrypoint>", "entrypoint alias from pipeline.yaml").option("--workflow <workflow>", "workflow id from pipeline.yaml").action(runAction);
171
- program.command("pipe").description("Alias for run").argument("<description...>", "task description").option("--entrypoint <entrypoint>", "entrypoint alias from pipeline.yaml").option("--workflow <workflow>", "workflow id from pipeline.yaml").action(runAction);
172
- program.command("validate").description("Validate .pipeline/pipeline.yaml and compile the workflow plan").option("--entrypoint <entrypoint>", "entrypoint alias from pipeline.yaml").option("--strict", "fail when validation lint warnings are emitted").option("--no-lint", "skip validation lint warnings").option("--workflow <workflow>", "workflow id from pipeline.yaml").action((flags) => {
207
+ program.command("run").description("Run a workflow from .pipeline/pipeline.yaml").argument("<description...>", "task description").option("--entrypoint <entrypoint>", "entrypoint alias from pipeline.yaml").option("--schedule <schedule>", "approved schedule YAML to execute").option("--workflow <workflow>", "workflow id from pipeline.yaml").action(runAction);
208
+ program.command("pipe").description("Alias for run").argument("<description...>", "task description").option("--entrypoint <entrypoint>", "entrypoint alias from pipeline.yaml").option("--schedule <schedule>", "approved schedule YAML to execute").option("--workflow <workflow>", "workflow id from pipeline.yaml").action(runAction);
209
+ program.command("validate").description("Validate .pipeline/pipeline.yaml and compile the workflow plan").option("--entrypoint <entrypoint>", "entrypoint alias from pipeline.yaml").option("--schedule <schedule>", "approved schedule YAML to validate").option("--strict", "fail when validation lint warnings are emitted").option("--no-lint", "skip validation lint warnings").option("--workflow <workflow>", "workflow id from pipeline.yaml").action((flags) => {
173
210
  const cwd = process.env.PIPELINE_TARGET_PATH ?? process.cwd();
174
211
  const config = loadPipelineConfig(cwd, { allowMissingLintFileReferences: true });
175
- const plan = compileWorkflowPlan(config, resolveWorkflowSelection(config, flags.workflow, flags.entrypoint));
212
+ const plan = flags.schedule ? compileScheduleArtifact(config, parseScheduleArtifact(readFileSync(flags.schedule, "utf8"), flags.schedule), cwd).plan : compileWorkflowPlan(config, resolveWorkflowSelection(config, flags.workflow, flags.entrypoint));
176
213
  const warnings = flags.lint === false ? [] : lintPipelineConfig(config, cwd);
177
214
  for (const warning of warnings) console.error(formatConfigLintWarning(warning));
178
215
  if (flags.strict && warnings.length > 0) throw new Error(`Validation failed with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}.`);
179
216
  console.log(`OK: ${plan.workflowId} (${plan.topologicalOrder.length} nodes)`);
180
217
  });
181
- program.command("explain-plan").description("Explain workflow nodes, runners, gates, hooks, and artifacts").option("--entrypoint <entrypoint>", "entrypoint alias from pipeline.yaml").option("--workflow <workflow>", "workflow id from pipeline.yaml").action((flags) => {
218
+ program.command("explain-plan").description("Explain workflow nodes, runners, gates, hooks, and artifacts").option("--entrypoint <entrypoint>", "entrypoint alias from pipeline.yaml").option("--schedule <schedule>", "approved schedule YAML to explain").option("--workflow <workflow>", "workflow id from pipeline.yaml").action((flags) => {
182
219
  const cwd = process.env.PIPELINE_TARGET_PATH ?? process.cwd();
183
220
  const config = loadPipelineConfig(cwd, { allowMissingLintFileReferences: true });
184
- console.log(formatWorkflowPlan(config, cwd, resolveWorkflowSelection(config, flags.workflow, flags.entrypoint)));
221
+ console.log(formatSelectedWorkflowPlan(config, cwd, flags));
185
222
  });
186
223
  program.command("doctor").description("Check local prerequisites for pipeline init and execution").action(async () => {
187
224
  const result = await runDoctor(process.env.PIPELINE_TARGET_PATH ?? process.cwd());
@@ -414,7 +451,16 @@ function hasExitCode(err) {
414
451
  return err instanceof Error && "exitCode" in err && typeof err.exitCode === "number";
415
452
  }
416
453
  function formatWorkflowPlan(config, worktreePath, workflowId) {
417
- const plan = compileWorkflowPlan(config, workflowId);
454
+ return formatCompiledWorkflowPlan(config, worktreePath, compileWorkflowPlan(config, workflowId));
455
+ }
456
+ function formatSelectedWorkflowPlan(config, worktreePath, flags) {
457
+ if (flags.schedule) {
458
+ const compiled = compileScheduleArtifact(config, parseScheduleArtifact(readFileSync(flags.schedule, "utf8"), flags.schedule), worktreePath);
459
+ return formatCompiledWorkflowPlan(compiled.config, worktreePath, compiled.plan);
460
+ }
461
+ return formatWorkflowPlan(config, worktreePath, resolveWorkflowSelection(config, flags.workflow, flags.entrypoint));
462
+ }
463
+ function formatCompiledWorkflowPlan(config, worktreePath, plan) {
418
464
  const workflow = config.workflows[plan.workflowId];
419
465
  const lines = [`Workflow: ${plan.workflowId}`];
420
466
  lines.push(formatOrchestratorPlan(config, worktreePath));
@@ -449,6 +495,7 @@ function resolveWorkflowSelection(config, workflowId, entrypointId) {
449
495
  if (!entrypointId) return;
450
496
  const entrypoint = config.entrypoints[entrypointId];
451
497
  if (!entrypoint) throw new Error(`Unknown pipeline entrypoint '${entrypointId}'`);
498
+ if ("schedule" in entrypoint) throw new Error(`Pipeline entrypoint '${entrypointId}' generates schedule '${entrypoint.schedule}'; use the entrypoint to create a schedule, then run with --schedule.`);
452
499
  return entrypoint.workflow;
453
500
  }
454
501
  function formatOrchestratorPlan(config, worktreePath) {
@@ -169,6 +169,15 @@ function dispatchBlock(host, config, workflowId = config.default_workflow) {
169
169
  hostSpecificDispatchGuard(host, nativeRoutes, cliRoutes)
170
170
  ].filter((line) => Boolean(line)).join("\n");
171
171
  }
172
+ function entrypointDispatchBlock(host, config, id, entrypoint) {
173
+ if ("workflow" in entrypoint) return dispatchBlock(host, config, entrypoint.workflow);
174
+ return [
175
+ `Generate a schedule for entrypoint \`${id}\` and the user task.`,
176
+ `The schedule policy is \`${entrypoint.schedule}\`.`,
177
+ `Run \`pipe run --entrypoint ${id} <task description>\` to write the schedule artifact, then stop for approval.`,
178
+ "Do not execute workflow nodes until the user runs `pipe run --schedule <schedule.yaml>`."
179
+ ].join("\n");
180
+ }
172
181
  function nativeDispatchBlock(host, routes) {
173
182
  if (routes.length === 0) return;
174
183
  return [
@@ -265,7 +274,7 @@ function opencodeDefinitions(config) {
265
274
  "",
266
275
  orchestratorBlock(config),
267
276
  "",
268
- dispatchBlock("opencode", config, entrypoint.workflow)
277
+ entrypointDispatchBlock("opencode", config, id, entrypoint)
269
278
  ]).join("\n")),
270
279
  host: "opencode",
271
280
  invocation: invocationForHost("opencode", id),
@@ -323,7 +332,7 @@ function codexDefinitions(config, cwd) {
323
332
  "",
324
333
  orchestratorBlock(config),
325
334
  "",
326
- dispatchBlock("codex", config, entrypoint.workflow)
335
+ entrypointDispatchBlock("codex", config, id, entrypoint)
327
336
  ]).join("\n")),
328
337
  host: "codex",
329
338
  invocation: invocationForHost("codex", id),
@@ -342,7 +351,7 @@ function codexDefinitions(config, cwd) {
342
351
  "",
343
352
  orchestratorBlock(config),
344
353
  "",
345
- dispatchBlock("codex", config, entrypoint.workflow)
354
+ entrypointDispatchBlock("codex", config, id, entrypoint)
346
355
  ]).join("\n")),
347
356
  host: "codex",
348
357
  invocation: codexPluginInvocation(id),
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, writeFileSync } from "node:fs";
1
+ import { mkdtempSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  //#region src/mcp/launch-plan.ts
@@ -23,14 +23,22 @@ function mcpArgsFor(runnerType, servers, hasPipelineConfig) {
23
23
  return [];
24
24
  }
25
25
  function mcpEnvFor(input, servers) {
26
- const declaredServers = input.config?.mcp_servers ?? {};
27
- if (input.runnerType !== "opencode" || Object.keys(servers).length === 0 && Object.keys(declaredServers).length === 0) return {};
28
- const config = toOpenCodeMcpConfig(servers, declaredServers);
29
- const path = join(mkdtempSync(join(tmpdir(), "pipeline-opencode-mcp-")), `${input.nodeId}.json`);
30
- writeFileSync(path, JSON.stringify(config));
26
+ if (input.runnerType !== "opencode" || !input.config) return {};
27
+ const config = toOpenCodeMcpConfig(servers);
28
+ const dir = mkdtempSync(join(tmpdir(), "pipeline-opencode-runtime-"));
31
29
  return {
32
- OPENCODE_CONFIG: path,
33
- PIPELINE_WORKTREE: input.worktreePath
30
+ OPENCODE_AUTH_CONTENT: void 0,
31
+ OPENCODE_CONFIG: void 0,
32
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
33
+ OPENCODE_CONFIG_DIR: void 0,
34
+ OPENCODE_DISABLE_PROJECT_CONFIG: "1",
35
+ OPENCODE_TEST_HOME: join(dir, "home"),
36
+ PIPELINE_OPENCODE_RUNTIME_DIR: dir,
37
+ PIPELINE_WORKTREE: input.worktreePath,
38
+ XDG_CACHE_HOME: join(dir, "cache"),
39
+ XDG_CONFIG_HOME: join(dir, "config"),
40
+ XDG_DATA_HOME: join(dir, "data"),
41
+ XDG_STATE_HOME: join(dir, "state")
34
42
  };
35
43
  }
36
44
  function isRemoteMcpServer(server) {
@@ -41,10 +49,10 @@ function headersWithBearerTokenEnv(server, renderTokenRef) {
41
49
  if (server.bearer_token_env_var) headers.Authorization = `Bearer ${renderTokenRef(server.bearer_token_env_var)}`;
42
50
  return Object.keys(headers).length > 0 ? headers : void 0;
43
51
  }
44
- function toOpenCodeMcpConfig(selectedServers, declaredServers) {
45
- return { mcp: {
46
- ...Object.fromEntries(Object.keys(declaredServers).filter((id) => !selectedServers[id]).map((id) => [id, { enabled: false }])),
47
- ...Object.fromEntries(Object.entries(selectedServers).map(([id, server]) => {
52
+ function toOpenCodeMcpConfig(selectedServers) {
53
+ return {
54
+ $schema: "https://opencode.ai/config.json",
55
+ mcp: { ...Object.fromEntries(Object.entries(selectedServers).map(([id, server]) => {
48
56
  if (isRemoteMcpServer(server)) {
49
57
  const headers = headersWithBearerTokenEnv(server, (envVar) => `{env:${envVar}}`);
50
58
  return [id, {
@@ -60,8 +68,8 @@ function toOpenCodeMcpConfig(selectedServers, declaredServers) {
60
68
  ...server.env ? { environment: server.env } : {},
61
69
  type: "local"
62
70
  }];
63
- }))
64
- } };
71
+ })) }
72
+ };
65
73
  }
66
74
  function codexMcpArgs(servers) {
67
75
  return Object.entries(servers).flatMap(([id, server]) => {
@@ -23,13 +23,13 @@ default_workflow: default
23
23
 
24
24
  entrypoints:
25
25
  pipe:
26
- workflow: default
26
+ schedule: pipe-schedule
27
27
  description: Full pipeline
28
28
  inspect:
29
29
  workflow: inspect
30
30
  description: Read-only repository inspection
31
31
  epic:
32
- workflow: epic-drain
32
+ schedule: epic-schedule
33
33
  description: Route an epic's tickets into specialist tracks, run them in parallel, then thermo-nuclear review.
34
34
 
35
35
  orchestrator:
@@ -60,6 +60,14 @@ hooks:
60
60
  timeout_ms: 5000
61
61
  output_limit_bytes: 4096
62
62
 
63
+ schedules:
64
+ pipe-schedule:
65
+ baseline: pipe
66
+ planner_profile: pipeline-schedule-planner
67
+ epic-schedule:
68
+ baseline: epic
69
+ planner_profile: pipeline-schedule-planner
70
+
63
71
  workflows:
64
72
  inspect:
65
73
  description: Read-only repository inspection workflow.
@@ -410,6 +418,22 @@ profiles:
410
418
  mode: inherit
411
419
  output:
412
420
  format: text
421
+ pipeline-schedule-planner:
422
+ runner: codex
423
+ description: Refine a baseline schedule into a specialized approved-plan artifact.
424
+ instructions:
425
+ path: .pipeline/prompts/schedule-planner.md
426
+ skills: [research, scope]
427
+ mcp_servers: [serena, context7, backlog, qdrant, github-readonly]
428
+ tools: [read, list, grep, glob, bash]
429
+ filesystem:
430
+ mode: read-only
431
+ allow: ["**/*"]
432
+ deny: ["node_modules/**", "dist/**", ".git/**"]
433
+ network:
434
+ mode: inherit
435
+ output:
436
+ format: text
413
437
  pipeline-test-writer:
414
438
  runner: codex
415
439
  description: Add focused failing tests for the requested behavior.
@@ -638,6 +662,18 @@ const SCAFFOLD_FILES = {
638
662
  "Do not modify files.",
639
663
  ""
640
664
  ].join("\n"),
665
+ ".pipeline/prompts/schedule-planner.md": [
666
+ "# Schedule planner",
667
+ "",
668
+ "Refine the provided baseline into a specialized `pipeline-schedule` YAML artifact for the user task.",
669
+ "",
670
+ "Keep the graph auditable: every workflow must be embedded in the artifact, every `kind: workflow` reference must point to an embedded workflow, and execution must include research, implementation, and verification.",
671
+ "",
672
+ "Use parallel branches only when they reduce coordination risk. Preserve configured profile ids, gates, hooks, retries, artifacts, and worktree policy unless the task clearly needs a different valid graph.",
673
+ "",
674
+ "Return only YAML. Do not wrap it in Markdown fences. Do not modify files. Do not invoke other agents.",
675
+ ""
676
+ ].join("\n"),
641
677
  ".pipeline/prompts/test-writer.md": [
642
678
  "You are the RED/test-write phase for the pipeline.",
643
679
  "Add focused failing tests for the requested behavior only.",
@@ -293,6 +293,7 @@ function resolveWorkflowSelection(config, workflowId, entrypointId) {
293
293
  if (!entrypointId) return;
294
294
  const entrypoint = config.entrypoints[entrypointId];
295
295
  if (!entrypoint) throw new Error(`Unknown pipeline entrypoint '${entrypointId}'`);
296
+ if ("schedule" in entrypoint) throw new Error(`Pipeline entrypoint '${entrypointId}' generates schedule '${entrypoint.schedule}'; run an approved schedule artifact instead.`);
296
297
  return entrypoint.workflow;
297
298
  }
298
299
  function failedRuntimeResult(context, nodes, failure) {