pipeline-sdk 0.1.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/package.json +63 -0
  4. package/schemas/pipeline.schema.json +158 -0
  5. package/src/adapters/claude-code.ts +112 -0
  6. package/src/adapters/detector.ts +26 -0
  7. package/src/adapters/generic.ts +30 -0
  8. package/src/adapters/interface.ts +7 -0
  9. package/src/cli/advance.ts +27 -0
  10. package/src/cli/cleanup.ts +55 -0
  11. package/src/cli/helpers.ts +52 -0
  12. package/src/cli/index.ts +92 -0
  13. package/src/cli/init.ts +248 -0
  14. package/src/cli/resume.ts +45 -0
  15. package/src/cli/signal.ts +21 -0
  16. package/src/cli/start.ts +33 -0
  17. package/src/cli/status.ts +24 -0
  18. package/src/cli/template.ts +28 -0
  19. package/src/cli/validate.ts +21 -0
  20. package/src/cli/verify.ts +33 -0
  21. package/src/cli/visualize.ts +36 -0
  22. package/src/core/cleanup.ts +75 -0
  23. package/src/core/evidence.ts +144 -0
  24. package/src/core/gate-runner.ts +109 -0
  25. package/src/core/loader.ts +125 -0
  26. package/src/core/state-machine.ts +119 -0
  27. package/src/daemon/ipc.ts +56 -0
  28. package/src/daemon/server.ts +144 -0
  29. package/src/daemon/state-file.ts +65 -0
  30. package/src/gates/async.ts +60 -0
  31. package/src/gates/builtin.ts +40 -0
  32. package/src/gates/custom.ts +71 -0
  33. package/src/index.ts +20 -0
  34. package/src/mcp/prompts.ts +40 -0
  35. package/src/mcp/resources.ts +71 -0
  36. package/src/mcp/server.ts +211 -0
  37. package/src/mcp/tools.ts +52 -0
  38. package/src/templates/infra-gitops.yaml +37 -0
  39. package/src/templates/sdlc-full.yaml +69 -0
  40. package/src/templates/static-site.yaml +45 -0
  41. package/src/templates/zship.yaml +224 -0
  42. package/src/types.ts +210 -0
@@ -0,0 +1,144 @@
1
+ import { unlink, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { StateMachine } from "../core/state-machine";
4
+ import type { PipelineDefinition, PipelineState } from "../types";
5
+ import {
6
+ createAdvanceResponse,
7
+ createCheckResponse,
8
+ createSignalResponse,
9
+ createStatusResponse,
10
+ } from "./ipc";
11
+ import { StateFile } from "./state-file";
12
+
13
+ export class PipelineDaemon {
14
+ private readonly dir: string;
15
+ private sm: StateMachine;
16
+ private stateFile: StateFile;
17
+ private server: ReturnType<typeof Bun.serve> | null = null;
18
+ private signals: Map<string, { signal: string; source?: string }> = new Map();
19
+
20
+ constructor(pipeline: PipelineDefinition, dir: string, state?: PipelineState) {
21
+ this.dir = dir;
22
+ this.sm = state ? StateMachine.fromState(pipeline, state) : new StateMachine(pipeline);
23
+ this.stateFile = new StateFile(join(dir, "state.json"));
24
+ }
25
+
26
+ async start(): Promise<{ port: number; pid: number }> {
27
+ if (this.server) {
28
+ throw new Error("Daemon is already running. Call stop() first.");
29
+ }
30
+ const daemon = this;
31
+
32
+ this.server = Bun.serve({
33
+ hostname: "127.0.0.1",
34
+ port: 0,
35
+ async fetch(req) {
36
+ try {
37
+ const url = new URL(req.url);
38
+ const path = url.pathname;
39
+
40
+ if (req.method === "GET" && path === "/api/status") {
41
+ const stageDef = daemon.sm.currentStageDefinition;
42
+ const gatesRemaining = stageDef?.gates?.exit ?? [];
43
+ const state = daemon.sm.exportState();
44
+ const body = createStatusResponse(
45
+ daemon.sm.currentStage,
46
+ gatesRemaining,
47
+ state.started_at,
48
+ daemon.sm.completedStages,
49
+ daemon.sm.allowedEvents,
50
+ daemon.sm.allowedTools,
51
+ );
52
+ return Response.json(body);
53
+ }
54
+
55
+ if (req.method === "POST" && path === "/api/check") {
56
+ const body = (await req.json()) as { tool?: string; command?: string };
57
+ if (!body.tool || typeof body.tool !== "string") {
58
+ return Response.json(
59
+ { error: "Missing required field: tool (string)" },
60
+ { status: 400 },
61
+ );
62
+ }
63
+ const tool = body.tool ?? "";
64
+ const allowedTools = daemon.sm.allowedTools;
65
+ // If no whitelist defined, allow all tools
66
+ const allowed = allowedTools.length === 0 || allowedTools.includes(tool);
67
+ const reason = allowed
68
+ ? undefined
69
+ : `Tool "${tool}" is not in the allowed list for stage "${daemon.sm.currentStage}"`;
70
+ const resp = createCheckResponse(allowed, daemon.sm.currentStage, allowedTools, reason);
71
+ return Response.json(resp);
72
+ }
73
+
74
+ if (req.method === "POST" && path === "/api/advance") {
75
+ const body = (await req.json()) as { event?: string };
76
+ if (!body.event || typeof body.event !== "string") {
77
+ return Response.json(
78
+ { error: "Missing required field: event (string)" },
79
+ { status: 400 },
80
+ );
81
+ }
82
+ const event = body.event ?? "";
83
+ const result = daemon.sm.transition(event);
84
+ if (result.success) {
85
+ await daemon.stateFile.write(daemon.sm.exportState());
86
+ }
87
+ const resp = createAdvanceResponse(result.success, result.newStage, result.error);
88
+ return Response.json(resp);
89
+ }
90
+
91
+ if (req.method === "POST" && path === "/api/signal") {
92
+ const body = (await req.json()) as {
93
+ signal?: string;
94
+ gate_id?: string;
95
+ source?: string;
96
+ };
97
+ if (!body.gate_id || typeof body.gate_id !== "string") {
98
+ return Response.json(
99
+ { error: "Missing required field: gate_id (string)" },
100
+ { status: 400 },
101
+ );
102
+ }
103
+ const gateId = body.gate_id;
104
+ const signal = body.signal ?? "";
105
+ if (gateId) {
106
+ daemon.signals.set(gateId, { signal, source: body.source });
107
+ }
108
+ const resp = createSignalResponse(true, gateId);
109
+ return Response.json(resp);
110
+ }
111
+
112
+ return new Response("Not Found", { status: 404 });
113
+ } catch (err: unknown) {
114
+ if (err instanceof SyntaxError) {
115
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
116
+ }
117
+ return Response.json(
118
+ { error: err instanceof Error ? err.message : "Internal server error" },
119
+ { status: 500 },
120
+ );
121
+ }
122
+ },
123
+ });
124
+
125
+ const port = this.server.port ?? 0;
126
+ const pid = process.pid;
127
+
128
+ await writeFile(join(this.dir, "daemon.pid"), JSON.stringify({ port, pid }), "utf8");
129
+
130
+ return { port, pid };
131
+ }
132
+
133
+ async stop(): Promise<void> {
134
+ if (this.server) {
135
+ this.server.stop(true);
136
+ this.server = null;
137
+ }
138
+ try {
139
+ await unlink(join(this.dir, "daemon.pid"));
140
+ } catch {
141
+ // ignore if already gone
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,65 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { PipelineState } from "../types";
4
+
5
+ export class StateFile {
6
+ private readonly path: string;
7
+
8
+ constructor(path: string) {
9
+ this.path = path;
10
+ }
11
+
12
+ async write(state: PipelineState): Promise<void> {
13
+ const dir = dirname(this.path);
14
+ await mkdir(dir, { recursive: true });
15
+ const tmpPath = `${this.path}.tmp`;
16
+ await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf8");
17
+ await rename(tmpPath, this.path);
18
+ }
19
+
20
+ async read(): Promise<PipelineState | null> {
21
+ try {
22
+ const content = await readFile(this.path, "utf8");
23
+ return JSON.parse(content) as PipelineState;
24
+ } catch (err: unknown) {
25
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
26
+ return null;
27
+ }
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ async writeCleanupPending(stages: string[]): Promise<void> {
33
+ const dir = dirname(this.path);
34
+ await mkdir(dir, { recursive: true });
35
+ const cleanupPath = join(dir, "cleanup-pending.json");
36
+ await writeFile(cleanupPath, JSON.stringify(stages, null, 2), "utf8");
37
+ }
38
+
39
+ async readCleanupPending(): Promise<string[]> {
40
+ const dir = dirname(this.path);
41
+ const cleanupPath = join(dir, "cleanup-pending.json");
42
+ try {
43
+ const content = await readFile(cleanupPath, "utf8");
44
+ return JSON.parse(content) as string[];
45
+ } catch (err: unknown) {
46
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
47
+ return [];
48
+ }
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ async clearCleanupPending(): Promise<void> {
54
+ const dir = dirname(this.path);
55
+ const cleanupPath = join(dir, "cleanup-pending.json");
56
+ try {
57
+ await unlink(cleanupPath);
58
+ } catch (err: unknown) {
59
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
60
+ return;
61
+ }
62
+ throw err;
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,60 @@
1
+ import type { GateVerdict } from "../types";
2
+
3
+ interface SignalPayload {
4
+ signal: string;
5
+ source: string;
6
+ metadata?: Record<string, unknown>;
7
+ }
8
+
9
+ const APPROVE_SIGNALS = new Set(["APPROVED", "LGTM", "PASS"]);
10
+
11
+ export class AsyncGate {
12
+ private readonly gateId: string;
13
+ private readonly timeoutMs: number;
14
+ private resolve: ((verdict: GateVerdict) => void) | null = null;
15
+ private pending: Promise<GateVerdict> | null = null;
16
+ private timerId: ReturnType<typeof setTimeout> | null = null;
17
+
18
+ constructor(gateId: string, options: { timeoutMs: number }) {
19
+ this.gateId = gateId;
20
+ this.timeoutMs = options.timeoutMs;
21
+ }
22
+
23
+ deliver(payload: SignalPayload): void {
24
+ if (!this.resolve) return;
25
+
26
+ if (this.timerId) {
27
+ clearTimeout(this.timerId);
28
+ this.timerId = null;
29
+ }
30
+
31
+ const signal = payload.signal.toUpperCase();
32
+ const status: GateVerdict["status"] = APPROVE_SIGNALS.has(signal) ? "pass" : "fail";
33
+
34
+ this.resolve({
35
+ status,
36
+ message: `Gate "${this.gateId}" received signal "${payload.signal}" from "${payload.source}"`,
37
+ });
38
+ this.resolve = null;
39
+ }
40
+
41
+ wait(): Promise<GateVerdict> {
42
+ if (this.pending) return this.pending;
43
+
44
+ this.pending = new Promise<GateVerdict>((res) => {
45
+ this.resolve = res;
46
+
47
+ this.timerId = setTimeout(() => {
48
+ if (!this.resolve) return;
49
+ this.resolve({
50
+ status: "fail",
51
+ message: `Gate "${this.gateId}" timed out after ${this.timeoutMs}ms`,
52
+ });
53
+ this.resolve = null;
54
+ this.timerId = null;
55
+ }, this.timeoutMs);
56
+ });
57
+
58
+ return this.pending;
59
+ }
60
+ }
@@ -0,0 +1,40 @@
1
+ import { exec as execCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { GateEvaluator } from "../core/gate-runner";
4
+ import type { GateInput, GateVerdict } from "../types";
5
+
6
+ const exec = promisify(execCb);
7
+
8
+ export const BUILTIN_GATES: Record<string, { defaultCommand: string }> = {
9
+ "lint-pass": { defaultCommand: "npm run lint" },
10
+ "typecheck-pass": { defaultCommand: "npm run typecheck" },
11
+ "format-check": { defaultCommand: "npm run format:check" },
12
+ "unit-tests-pass": { defaultCommand: "npm test" },
13
+ "integration-tests-pass": { defaultCommand: "npm run test:integration" },
14
+ "e2e-tests-pass": { defaultCommand: "npm run test:e2e" },
15
+ "dep-audit-pass": { defaultCommand: "npm audit --audit-level=high" },
16
+ "build-succeeds": { defaultCommand: "npm run build" },
17
+ };
18
+
19
+ export function createBuiltinGate(
20
+ name: string,
21
+ config: { command?: string },
22
+ ): GateEvaluator | null {
23
+ const builtin = BUILTIN_GATES[name];
24
+ if (!builtin) return null;
25
+
26
+ const command = config.command ?? builtin.defaultCommand;
27
+
28
+ return async (input: GateInput): Promise<GateVerdict> => {
29
+ try {
30
+ await exec(command, {
31
+ cwd: input.workspace.root,
32
+ timeout: 300_000,
33
+ });
34
+ return { status: "pass", message: `${name}: command succeeded` };
35
+ } catch (err: unknown) {
36
+ const message = err instanceof Error ? err.message : `${name}: command failed`;
37
+ return { status: "fail", message };
38
+ }
39
+ };
40
+ }
@@ -0,0 +1,71 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { GateInput, GateVerdict } from "../types";
3
+
4
+ export class CustomGateRunner {
5
+ async evaluate(scriptPath: string, input: GateInput, timeoutMs = 300_000): Promise<GateVerdict> {
6
+ return new Promise((resolve) => {
7
+ let proc: ReturnType<typeof spawn>;
8
+
9
+ try {
10
+ proc = spawn(scriptPath, [], { stdio: ["pipe", "pipe", "pipe"] });
11
+ } catch (err: unknown) {
12
+ const message = err instanceof Error ? err.message : "Failed to spawn process";
13
+ resolve({ status: "fail", message });
14
+ return;
15
+ }
16
+
17
+ let stdout = "";
18
+ let stderr = "";
19
+
20
+ proc.stdout?.on("data", (chunk: Buffer) => {
21
+ stdout += chunk.toString();
22
+ });
23
+
24
+ proc.stderr?.on("data", (chunk: Buffer) => {
25
+ stderr += chunk.toString();
26
+ });
27
+
28
+ proc.on("error", (err: Error) => {
29
+ resolve({ status: "fail", message: err.message });
30
+ });
31
+
32
+ const timer = setTimeout(() => {
33
+ proc.kill();
34
+ resolve({ status: "fail", message: `Script timed out after ${timeoutMs}ms` });
35
+ }, timeoutMs);
36
+
37
+ proc.on("close", (code: number | null) => {
38
+ clearTimeout(timer);
39
+
40
+ const trimmed = stdout.trim();
41
+ if (trimmed) {
42
+ try {
43
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
44
+ if (typeof parsed.status === "string" && typeof parsed.message === "string") {
45
+ resolve(parsed as unknown as GateVerdict);
46
+ return;
47
+ }
48
+ } catch {
49
+ // fall through to exit-code fallback
50
+ }
51
+ }
52
+
53
+ // Exit-code fallback
54
+ if (code === 0) {
55
+ resolve({ status: "pass", message: "Script exited with code 0" });
56
+ } else {
57
+ const detail = stderr.trim() || `Script exited with code ${code ?? "null"}`;
58
+ resolve({ status: "fail", message: detail });
59
+ }
60
+ });
61
+
62
+ // Send input via stdin then close
63
+ try {
64
+ proc.stdin?.write(JSON.stringify(input));
65
+ proc.stdin?.end();
66
+ } catch {
67
+ // stdin may already be closed if the process failed immediately
68
+ }
69
+ });
70
+ }
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import pkg from "../package.json";
2
+
3
+ export const VERSION: string = pkg.version;
4
+ export { ClaudeCodeAdapter } from "./adapters/claude-code";
5
+ export { detectProviders } from "./adapters/detector";
6
+ export { GenericAdapter } from "./adapters/generic";
7
+ export type { ProviderAdapter } from "./adapters/interface";
8
+ export { CleanupManager } from "./core/cleanup";
9
+ export { EvidenceStore } from "./core/evidence";
10
+ export { GateRunner } from "./core/gate-runner";
11
+ export { loadPipeline, validatePipeline } from "./core/loader";
12
+ export { StateMachine } from "./core/state-machine";
13
+ export { PipelineDaemon } from "./daemon/server";
14
+ export { StateFile } from "./daemon/state-file";
15
+ export { AsyncGate } from "./gates/async";
16
+ export { BUILTIN_GATES, createBuiltinGate } from "./gates/builtin";
17
+ export { CustomGateRunner } from "./gates/custom";
18
+ export { createMcpServer } from "./mcp/server";
19
+ export { createPipelineTools } from "./mcp/tools";
20
+ export type * from "./types";
@@ -0,0 +1,40 @@
1
+ import type { PipelineDefinition } from "../types";
2
+
3
+ export function createStagePrompt(pipeline: PipelineDefinition, stageName: string): string {
4
+ const stageDef = pipeline.stages[stageName];
5
+
6
+ if (!stageDef) {
7
+ return `Stage "${stageName}" not found in pipeline "${pipeline.id}".`;
8
+ }
9
+
10
+ const allowedTools = stageDef.agent?.tools ?? [];
11
+ const allowedEvents = stageDef.on ? Object.keys(stageDef.on) : [];
12
+
13
+ const lines: string[] = [
14
+ `You are in the ${stageName.toUpperCase()} stage of the "${pipeline.id}" pipeline.`,
15
+ ];
16
+
17
+ if (stageDef.description) {
18
+ lines.push(`Description: ${stageDef.description}`);
19
+ }
20
+
21
+ if (stageDef.agent?.instructions) {
22
+ lines.push(`Instructions: ${stageDef.agent.instructions}`);
23
+ }
24
+
25
+ if (allowedTools.length > 0) {
26
+ lines.push(`Allowed tools: ${allowedTools.join(", ")}.`);
27
+ } else {
28
+ lines.push("All tools are allowed in this stage.");
29
+ }
30
+
31
+ if (allowedEvents.length > 0) {
32
+ lines.push(`When done, call pipeline_advance with one of: ${allowedEvents.join(", ")}.`);
33
+ }
34
+
35
+ if (allowedTools.length > 0) {
36
+ lines.push("Call pipeline_check before any tool use to verify it is allowed.");
37
+ }
38
+
39
+ return lines.join("\n");
40
+ }
@@ -0,0 +1,71 @@
1
+ import type { StateMachine } from "../core/state-machine";
2
+ import type { PipelineDefinition } from "../types";
3
+
4
+ export interface ResourceContent {
5
+ uri: string;
6
+ mimeType: string;
7
+ text: string;
8
+ }
9
+
10
+ export function createPipelineResources(
11
+ machine: StateMachine,
12
+ pipeline: PipelineDefinition,
13
+ ): Record<string, () => ResourceContent> {
14
+ return {
15
+ "pipeline://state/current"(): ResourceContent {
16
+ const state = machine.exportState();
17
+ const stageDef = machine.currentStageDefinition;
18
+ return {
19
+ uri: "pipeline://state/current",
20
+ mimeType: "application/json",
21
+ text: JSON.stringify({
22
+ stage: machine.currentStage,
23
+ gates: {
24
+ entry: stageDef?.gates?.entry ?? [],
25
+ exit: stageDef?.gates?.exit ?? [],
26
+ },
27
+ evidence_head: state.evidence_chain_head,
28
+ started_at: state.started_at,
29
+ is_terminal: machine.isTerminal,
30
+ }),
31
+ };
32
+ },
33
+
34
+ "pipeline://instructions/current"(): ResourceContent {
35
+ const stageDef = machine.currentStageDefinition;
36
+ return {
37
+ uri: "pipeline://instructions/current",
38
+ mimeType: "application/json",
39
+ text: JSON.stringify({
40
+ stage: machine.currentStage,
41
+ description: stageDef?.description ?? null,
42
+ tools_allowed: machine.allowedTools,
43
+ constraints: stageDef?.agent?.instructions ?? null,
44
+ allowed_events: machine.allowedEvents,
45
+ }),
46
+ };
47
+ },
48
+
49
+ "pipeline://gates/available"(): ResourceContent {
50
+ const stageDef = machine.currentStageDefinition;
51
+ const entryGateIds = stageDef?.gates?.entry ?? [];
52
+ const exitGateIds = stageDef?.gates?.exit ?? [];
53
+ const allGateIds = [...entryGateIds, ...exitGateIds];
54
+
55
+ const gates = allGateIds.map((id) => {
56
+ const gateDef = pipeline.gates?.[id];
57
+ return {
58
+ id,
59
+ type: gateDef?.type ?? "builtin",
60
+ description: gateDef?.signal ?? gateDef?.command ?? id,
61
+ };
62
+ });
63
+
64
+ return {
65
+ uri: "pipeline://gates/available",
66
+ mimeType: "application/json",
67
+ text: JSON.stringify(gates),
68
+ };
69
+ },
70
+ };
71
+ }