conductor-board 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mettafive
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # conductor-board
2
+
3
+ The CLI and live local Kanban board for [Agent Conductor](../README.md). It
4
+ watches `.conductor/status.json` and renders your conductor workflow in real time
5
+ as the agent executes it β€” and scaffolds and validates conductors.
6
+
7
+ ```bash
8
+ npx conductor-board
9
+ ```
10
+
11
+ ```
12
+ 🎼 conductor-board
13
+ Board live at http://localhost:3042 β€” watching .conductor/status.json
14
+ ```
15
+
16
+ ## Commands
17
+
18
+ ```bash
19
+ npx conductor-board # serve the live board (default)
20
+ npx conductor-board init # scaffold .conductor/conductor.yaml
21
+ npx conductor-board init --name w --steps 4 # non-interactive scaffold
22
+ npx conductor-board validate [path] # check a conductor against the spec
23
+ npx conductor-board --help # all commands + options
24
+ ```
25
+
26
+ ## What it does
27
+
28
+ - **Watches** `.conductor/status.json` via `fs.watch` and streams changes to the
29
+ browser over **Server-Sent Events** β€” no polling, no WebSocket.
30
+ - **Merges** the live status with the conductor definition (auto-discovered next
31
+ to the status file) so cards show the full picture: instruction, gate criteria,
32
+ soft/hard split, `requires`, conditions, outputs.
33
+ - **Renders** a board with columns **Pending β†’ Running β†’ Gate Check β†’ Done** and a
34
+ **Failed** side column. Cards animate between columns as status changes.
35
+ - **Archives** every completed/failed run to `.conductor/history/` and shows a
36
+ **history sidebar** β€” click any past run to view its frozen final state.
37
+
38
+ ## Options
39
+
40
+ | Flag | Default | Description |
41
+ | --- | --- | --- |
42
+ | `--path`, `-p` | `.conductor/status.json` | Path to the status file |
43
+ | `--conductor`, `-c` | auto-discovered | Path to the conductor `.yaml` |
44
+ | `--port` | `3042` | Port to serve on (walks forward if taken) |
45
+ | `--no-open` | β€” | Don't open the browser |
46
+ | `--help`, `-h` | β€” | Show help |
47
+
48
+ ```bash
49
+ npx conductor-board --path ./run/status.json --port 3001
50
+ npx conductor-board --conductor ./workflows/review.yaml
51
+ ```
52
+
53
+ The board reads `status.json` for live **state** and the conductor file for step
54
+ **structure**. If no conductor file is found, cards degrade gracefully to
55
+ status-only.
56
+
57
+ ## History
58
+
59
+ When a run reaches `done` or `failed`, the server archives a **self-contained**
60
+ record (the final status plus the conductor that produced it) to
61
+ `.conductor/history/<run_id>_<workflow>.json`. The board's history sidebar lists past runs
62
+ grouped by workflow; selecting one shows its frozen final state. Deep-link a run
63
+ with `?run=<run_id>`.
64
+
65
+ Give each run a distinct `run_id` (a timestamp works well) in `status.json` so
66
+ runs archive cleanly instead of overwriting each other.
67
+
68
+ | Endpoint | Returns |
69
+ | --- | --- |
70
+ | `GET /api/state` | current `{ status, conductorYaml }` snapshot |
71
+ | `GET /history` | summaries of archived runs, newest first |
72
+ | `GET /history/:filename` | one full archived run (also resolves by `run_id`) |
73
+ | `GET /events` | SSE stream of `update` and `history` events |
74
+
75
+ ## Card anatomy
76
+
77
+ - Step ID, first line of the instruction, and soft/hard gate badges
78
+ - Attempt counter (`Γ—2`) when a step has been retried
79
+ - Condition steps show a fork icon and the branch taken
80
+ - Loop steps (`type: loop`) show an `n/total` iterations bar; expand to see each
81
+ iteration's sub-step statuses and per-iteration retries
82
+ - `requires` dependencies render as a chip on the card
83
+ - Click a card to expand its gate criteria with per-criterion pass/fail
84
+
85
+ ## Architecture
86
+
87
+ ```
88
+ .conductor/status.json ──fs.watch──┐
89
+ .conductor/conductor.yaml ──────────
90
+ β–Ό
91
+ server (zero-dep node:http)
92
+ β”œβ”€β”€ serves dist/ (React app)
93
+ └── /events (Server-Sent Events)
94
+ β–Ό
95
+ browser: parse + merge + render
96
+ ```
97
+
98
+ The **board server has zero runtime dependencies** β€” plain `node:http`, `fs`, and
99
+ `fs.watch`. YAML is parsed in the browser, so the server never needs a parser. The
100
+ package's only dependency is `js-yaml`, used by the `validate` command (it has no
101
+ dependencies of its own, so `npx` stays instant).
102
+
103
+ ## Develop
104
+
105
+ ```bash
106
+ npm install
107
+ npm run build # build the React app into dist/
108
+ npm start # serve the board (node bin/cli.js)
109
+
110
+ # drive a demo workflow through the board to see the animations + history:
111
+ npm run simulate -- --fail security-audit # one run, with a retry
112
+ npm run simulate -- --fatal coverage-check # a run that ends failed
113
+ npm run simulate -- --loop # keep archiving runs
114
+ npm run simulate -- ../examples/batch-review.yaml --fail critique # a loop run
115
+ ```
116
+
117
+ `scripts/simulate.js` is a dev-only tool that walks a conductor and writes
118
+ `status.json` over time (it is not part of the published package).
package/bin/cli.js ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { startServer } from "../server/server.js";
6
+
7
+ const argv = process.argv.slice(2);
8
+ const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : null;
9
+ const rest = argv.slice(1);
10
+
11
+ function flag(names, fallback) {
12
+ for (const name of names) {
13
+ const i = argv.indexOf(name);
14
+ if (i !== -1) {
15
+ const next = argv[i + 1];
16
+ return next && !next.startsWith("-") ? next : true;
17
+ }
18
+ }
19
+ return fallback;
20
+ }
21
+
22
+ const HELP = `
23
+ conductor-board β€” gated workflows for AI agents, with a live Kanban board
24
+
25
+ Usage
26
+ $ npx conductor-board [command] [options]
27
+
28
+ Commands
29
+ (default) Serve the live board (watches .conductor/)
30
+ init Scaffold a new .conductor/conductor.yaml
31
+ validate [path] Check a conductor against the spec
32
+ setup Write setup.conductor.yaml (the bootstrap conductor)
33
+
34
+ Board options
35
+ --path, -p <file> Path to status.json (default: .conductor/status.json)
36
+ --conductor, -c <file> Path to the conductor (default: auto-discovered)
37
+ --port <n> Port to serve on (default: 3042)
38
+ --no-open Don't open the browser
39
+
40
+ init options
41
+ --name, -n <name> Workflow name (skips the prompts)
42
+ --description, -d <text> One-line description
43
+ --steps, -s <n> Number of placeholder steps
44
+ --force, -f Overwrite an existing conductor.yaml
45
+
46
+ --help, -h Show this help
47
+
48
+ Examples
49
+ $ npx conductor-board
50
+ $ npx conductor-board init --name clinic-update --steps 4
51
+ $ npx conductor-board validate .conductor/conductor.yaml
52
+ `;
53
+
54
+ // ---- subcommands ----
55
+ if (command === "help" || (!command && flag(["--help", "-h"], false))) {
56
+ console.log(HELP);
57
+ process.exit(0);
58
+ }
59
+
60
+ if (command === "init") {
61
+ const { runInit } = await import("../cli/init.js");
62
+ process.exit((await runInit(rest)) ? 0 : 1);
63
+ }
64
+
65
+ if (command === "validate") {
66
+ const { runValidate } = await import("../cli/validate.js");
67
+ process.exit((await runValidate(rest)) ? 0 : 1);
68
+ }
69
+
70
+ if (command === "setup") {
71
+ const { runSetup } = await import("../cli/setup.js");
72
+ process.exit((await runSetup(rest)) ? 0 : 1);
73
+ }
74
+
75
+ if (command && command !== "board") {
76
+ console.error(`Unknown command "${command}". Run with --help to see usage.`);
77
+ process.exit(1);
78
+ }
79
+
80
+ // ---- default: serve the board ----
81
+ const statusPath = String(flag(["--path", "-p"], ".conductor/status.json"));
82
+ const conductorArg = flag(["--conductor", "-c"], null);
83
+ const conductorPath = conductorArg ? path.resolve(process.cwd(), String(conductorArg)) : null;
84
+ const wantedPort = Number(flag(["--port"], 3042)) || 3042;
85
+ const noOpen = flag(["--no-open"], false) === true;
86
+
87
+ function openBrowser(url) {
88
+ const cmd =
89
+ process.platform === "darwin"
90
+ ? "open"
91
+ : process.platform === "win32"
92
+ ? "cmd"
93
+ : "xdg-open";
94
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
95
+ try {
96
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
97
+ } catch {
98
+ /* opening the browser is best-effort */
99
+ }
100
+ }
101
+
102
+ // Try the requested port, walk forward a few if it's taken.
103
+ async function listenWithFallback(port, attempts = 10) {
104
+ for (let i = 0; i < attempts; i++) {
105
+ try {
106
+ return await startServer({ statusPath, conductorPath, port: port + i });
107
+ } catch (e) {
108
+ if (e && e.code === "EADDRINUSE") continue;
109
+ throw e;
110
+ }
111
+ }
112
+ throw new Error(`No free port in range ${port}-${port + attempts - 1}`);
113
+ }
114
+
115
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
116
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
117
+ const iris = (s) => `\x1b[38;5;141m${s}\x1b[0m`;
118
+ const mint = (s) => `\x1b[38;5;78m${s}\x1b[0m`;
119
+
120
+ const { conductorPath: resolvedConductor, absStatus, server, serverJsonPath } =
121
+ await listenWithFallback(wantedPort);
122
+ const port = server.address().port;
123
+ const url = `http://localhost:${port}`;
124
+ const rel = (p) => (p ? path.relative(process.cwd(), p) || p : null);
125
+
126
+ console.log("");
127
+ console.log(` ${iris("🎼 conductor-board")}`);
128
+ console.log(` ${bold("Board live at")} ${mint(url)} ${dim("β€” watching " + rel(absStatus))}`);
129
+ if (resolvedConductor) {
130
+ console.log(` ${dim("conductor: " + rel(resolvedConductor))}`);
131
+ } else {
132
+ console.log(` ${dim("conductor: not found β€” cards show status only")}`);
133
+ }
134
+ console.log(` ${dim("press ctrl+c to stop")}`);
135
+ console.log("");
136
+
137
+ if (!noOpen) openBrowser(url);
138
+
139
+ function shutdown() {
140
+ try {
141
+ if (serverJsonPath) fs.unlinkSync(serverJsonPath);
142
+ } catch {
143
+ /* already gone */
144
+ }
145
+ console.log(dim("\n board stopped\n"));
146
+ process.exit(0);
147
+ }
148
+
149
+ process.on("SIGINT", shutdown);
150
+ process.on("SIGTERM", shutdown);
package/cli/init.js ADDED
@@ -0,0 +1,87 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+
5
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
6
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
7
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
8
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
9
+
10
+ function flag(args, names) {
11
+ for (const name of names) {
12
+ const i = args.indexOf(name);
13
+ if (i !== -1) {
14
+ const next = args[i + 1];
15
+ return next && !next.startsWith("-") ? next : true;
16
+ }
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ function buildYaml({ name, description, steps }) {
22
+ const lines = [
23
+ "conductor: 1.0.0",
24
+ `name: ${name}`,
25
+ `description: ${description}`,
26
+ "",
27
+ "steps:",
28
+ ];
29
+ for (let i = 1; i <= steps; i++) {
30
+ lines.push(` - id: step-${i}`);
31
+ lines.push(" instruction: |");
32
+ lines.push(" TODO: Describe what to do in this step.");
33
+ if (i > 1) lines.push(` requires: [step-${i - 1}]`);
34
+ lines.push(" gate:");
35
+ lines.push(' - "TODO: Add validation criteria"');
36
+ lines.push("");
37
+ }
38
+ return lines.join("\n").replace(/\n+$/, "\n");
39
+ }
40
+
41
+ export async function runInit(args) {
42
+ const force = flag(args, ["--force", "-f"]) === true;
43
+ const dir = path.resolve(process.cwd(), String(flag(args, ["--dir"]) ?? ".conductor"));
44
+ const target = path.join(dir, "conductor.yaml");
45
+
46
+ let name = flag(args, ["--name", "-n"]);
47
+ let description = flag(args, ["--description", "-d"]);
48
+ let stepsRaw = flag(args, ["--steps", "-s"]);
49
+
50
+ // interactive unless a name was passed
51
+ const interactive = name === undefined;
52
+ if (interactive) {
53
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
54
+ try {
55
+ console.log("");
56
+ name = (await rl.question("? Workflow name: ")).trim() || "my-workflow";
57
+ description = (await rl.question("? Description: ")).trim() || "A gated agent workflow.";
58
+ stepsRaw = (await rl.question("? How many steps? ")).trim() || "3";
59
+ } finally {
60
+ rl.close();
61
+ }
62
+ }
63
+
64
+ name = String(name || "my-workflow").trim();
65
+ description = String(description ?? "A gated agent workflow.").trim();
66
+ let steps = parseInt(String(stepsRaw ?? "3"), 10);
67
+ if (!Number.isFinite(steps) || steps < 1) steps = 3;
68
+ steps = Math.min(steps, 50);
69
+
70
+ if (fs.existsSync(target) && !force) {
71
+ console.error("");
72
+ console.error(red(`βœ— ${path.relative(process.cwd(), target)} already exists.`));
73
+ console.error(dim(" Use --force to overwrite it."));
74
+ console.error("");
75
+ return false;
76
+ }
77
+
78
+ fs.mkdirSync(dir, { recursive: true });
79
+ fs.writeFileSync(target, buildYaml({ name, description, steps }));
80
+
81
+ const rel = path.relative(process.cwd(), target) || target;
82
+ console.log("");
83
+ console.log(`${green("βœ“")} Created ${bold(rel)} with ${steps} step${steps === 1 ? "" : "s"}.`);
84
+ console.log(dim(" Edit the steps, then run: ") + "conductor-board");
85
+ console.log("");
86
+ return true;
87
+ }
package/cli/setup.js ADDED
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
5
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
6
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
7
+
8
+ // The self-bootstrapping conductor. Every gate is a HARD gate β€” in the
9
+ // bootstrap, either it works or it doesn't, no soft judgment.
10
+ export const SETUP_YAML = `conductor: 1.0.0
11
+ name: conductor-board-bootstrap
12
+ description: Set up the board, convert a skill into a gated workflow, and execute it.
13
+
14
+ inputs:
15
+ - skill_content
16
+
17
+ steps:
18
+ - id: preflight
19
+ instruction: |
20
+ Verify the environment can run conductor-board.
21
+ Check that Node.js 18+ and npx are available.
22
+ gate:
23
+ - name: "Node.js installed"
24
+ check: "node --version"
25
+ - name: "npx available"
26
+ check: "npx --version"
27
+
28
+ - id: start-board
29
+ instruction: |
30
+ Start the board server in the background:
31
+ npx conductor-board &
32
+ Wait ~3 seconds for it to initialize. It auto-detects a free port if 3042
33
+ is taken and records the chosen port in .conductor/server.json.
34
+ requires: [preflight]
35
+ gate:
36
+ - name: "Server config file exists"
37
+ check: "test -f .conductor/server.json"
38
+ - name: "Server responds to health check"
39
+ check: "curl -sf http://localhost:$(node -p \\"require('./.conductor/server.json').port\\")/health -o /dev/null"
40
+
41
+ - id: read-skill
42
+ instruction: |
43
+ Read and analyze the user's skill content:
44
+
45
+ {skill_content}
46
+
47
+ Break it down into: the discrete sequential steps; decision points where
48
+ the flow could branch; repeated operations that need loops; and which
49
+ checks are verifiable by a shell command vs which need judgment.
50
+ Save your analysis to .conductor/skill-analysis.md.
51
+ requires: [start-board]
52
+ gate:
53
+ - name: "Analysis saved"
54
+ check: "test -f .conductor/skill-analysis.md"
55
+
56
+ - id: convert-to-conductor
57
+ instruction: |
58
+ Convert the analysis into a conductor YAML. Follow the format in
59
+ spec/conductor-spec.md and the examples/ directory. Rules:
60
+ - every step gets at least one gate criterion
61
+ - hard gates (check:) for anything verifiable by command; soft gates for judgment
62
+ - conditions (type: condition) where the flow branches
63
+ - loops (type: loop) where steps repeat over a list
64
+ - chain data between steps with output: and requires:
65
+ Save the conductor to .conductor/conductor.yaml.
66
+ requires: [read-skill]
67
+ gate:
68
+ - name: "Conductor file created"
69
+ check: "test -f .conductor/conductor.yaml"
70
+ - name: "Conductor passes validation"
71
+ check: "npx conductor-board validate .conductor/conductor.yaml"
72
+
73
+ - id: execute-workflow
74
+ instruction: |
75
+ Execute the generated conductor workflow.
76
+ Create .conductor/status.json with all steps pending and a timestamp run_id.
77
+ Walk each step in order, updating status.json after every step and gate
78
+ change. Retry on gate failure β€” never skip. Set the top-level status to
79
+ "done" when the last step completes.
80
+ requires: [convert-to-conductor]
81
+ gate:
82
+ - name: "Status file exists"
83
+ check: "test -f .conductor/status.json"
84
+ - name: "Workflow completed successfully"
85
+ check: "node -p \\"JSON.parse(require('fs').readFileSync('.conductor/status.json','utf8')).status\\" | grep done"
86
+ `;
87
+
88
+ export async function runSetup(args) {
89
+ const force = args.includes("--force") || args.includes("-f");
90
+ const target = path.resolve(process.cwd(), "setup.conductor.yaml");
91
+
92
+ if (fs.existsSync(target) && !force) {
93
+ console.log("");
94
+ console.log(dim(` setup.conductor.yaml already exists (use --force to replace).`));
95
+ console.log("");
96
+ return true;
97
+ }
98
+
99
+ fs.writeFileSync(target, SETUP_YAML);
100
+ console.log("");
101
+ console.log(`${green("βœ“")} Wrote ${bold("setup.conductor.yaml")}`);
102
+ console.log(dim(" Point your agent at it: \"Read setup.conductor.yaml and execute it.\""));
103
+ console.log("");
104
+ return true;
105
+ }
@@ -0,0 +1,201 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import yaml from "js-yaml";
4
+
5
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
6
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
7
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
8
+
9
+ const SEMVER = /^\d+\.\d+\.\d+$/;
10
+
11
+ function gateOk(g) {
12
+ if (typeof g === "string") return true;
13
+ return g && typeof g === "object" && typeof g.check === "string";
14
+ }
15
+
16
+ function countGates(steps, acc) {
17
+ for (const s of steps) {
18
+ for (const g of s.gate ?? []) {
19
+ if (typeof g === "string") acc.soft++;
20
+ else if (g && typeof g === "object" && typeof g.check === "string") acc.hard++;
21
+ }
22
+ if (s.type === "loop" && Array.isArray(s.steps)) countGates(s.steps, acc);
23
+ }
24
+ }
25
+
26
+ /** Validate sub-steps of a loop; pushes errors with a prefix. */
27
+ function validateSubSteps(loop, errors) {
28
+ const seen = new Set();
29
+ for (const sub of loop.steps ?? []) {
30
+ if (!sub || !sub.id) {
31
+ errors.push(`Loop "${loop.id}" has a sub-step with no id`);
32
+ continue;
33
+ }
34
+ if (seen.has(sub.id)) errors.push(`Loop "${loop.id}" has duplicate sub-step id "${sub.id}"`);
35
+ seen.add(sub.id);
36
+ if (!sub.instruction) errors.push(`Loop sub-step "${sub.id}" has no instruction`);
37
+ for (const g of sub.gate ?? []) {
38
+ if (!gateOk(g)) errors.push(`Loop sub-step "${sub.id}" has a malformed gate criterion`);
39
+ }
40
+ }
41
+ }
42
+
43
+ /** Reachability from the first step, following flow (not requires). */
44
+ function findOrphans(steps, ids) {
45
+ if (steps.length === 0) return [];
46
+ const indexById = new Map(steps.map((s, i) => [s.id, i]));
47
+ const successors = (s, i) => {
48
+ if (s.type === "condition") return [s.if_true, s.if_false].filter(Boolean);
49
+ if (s.then) return [s.then];
50
+ const next = steps[i + 1];
51
+ return next ? [next.id] : [];
52
+ };
53
+ const reachable = new Set();
54
+ const queue = [steps[0].id];
55
+ while (queue.length) {
56
+ const id = queue.shift();
57
+ if (reachable.has(id) || !ids.has(id)) continue;
58
+ reachable.add(id);
59
+ const i = indexById.get(id);
60
+ for (const n of successors(steps[i], i)) if (!reachable.has(n)) queue.push(n);
61
+ }
62
+ return steps.map((s) => s.id).filter((id) => !reachable.has(id));
63
+ }
64
+
65
+ /** Detect a cycle in the `requires` dependency graph. */
66
+ function hasRequiresCycle(steps, ids) {
67
+ const deps = new Map(steps.map((s) => [s.id, (s.requires ?? []).filter((d) => ids.has(d))]));
68
+ const state = new Map(); // white/gray/black
69
+ const dfs = (id) => {
70
+ state.set(id, "gray");
71
+ for (const d of deps.get(id) ?? []) {
72
+ const st = state.get(d);
73
+ if (st === "gray") return true;
74
+ if (st !== "black" && dfs(d)) return true;
75
+ }
76
+ state.set(id, "black");
77
+ return false;
78
+ };
79
+ for (const s of steps) if (state.get(s.id) !== "black" && dfs(s.id)) return true;
80
+ return false;
81
+ }
82
+
83
+ export function validateConductor(doc) {
84
+ const errors = [];
85
+ if (!doc || typeof doc !== "object") return ["File is empty or not a YAML mapping"];
86
+
87
+ for (const key of ["conductor", "name", "description", "steps"]) {
88
+ if (doc[key] === undefined) errors.push(`Missing required top-level key "${key}"`);
89
+ }
90
+ if (doc.conductor !== undefined && !SEMVER.test(String(doc.conductor))) {
91
+ errors.push(`"conductor" must be a semver version (e.g. 1.1.0), got "${doc.conductor}"`);
92
+ }
93
+
94
+ const steps = Array.isArray(doc.steps) ? doc.steps : [];
95
+ if (doc.steps !== undefined && !Array.isArray(doc.steps)) errors.push(`"steps" must be a list`);
96
+
97
+ const ids = new Set();
98
+ for (const s of steps) {
99
+ if (!s || typeof s !== "object" || !s.id) {
100
+ errors.push("A step is missing its id");
101
+ continue;
102
+ }
103
+ if (ids.has(s.id)) errors.push(`Duplicate step id "${s.id}"`);
104
+ ids.add(s.id);
105
+ }
106
+
107
+ for (const s of steps) {
108
+ if (!s || !s.id) continue;
109
+ const isCond = s.type === "condition";
110
+ const isLoop = s.type === "loop";
111
+
112
+ if (!isLoop && !s.instruction) errors.push(`Step "${s.id}" has no instruction`);
113
+
114
+ for (const g of s.gate ?? []) {
115
+ if (!gateOk(g)) errors.push(`Step "${s.id}" has a malformed gate criterion`);
116
+ }
117
+
118
+ if (isCond) {
119
+ if (!s.if_true) errors.push(`Step "${s.id}" is a condition but missing if_true`);
120
+ if (!s.if_false) errors.push(`Step "${s.id}" is a condition but missing if_false`);
121
+ }
122
+
123
+ if (isLoop) {
124
+ if (!s.over) errors.push(`Loop "${s.id}" is missing "over"`);
125
+ if (!s.as) errors.push(`Loop "${s.id}" is missing "as"`);
126
+ if (!Array.isArray(s.steps) || s.steps.length === 0)
127
+ errors.push(`Loop "${s.id}" has no sub-steps`);
128
+ else validateSubSteps(s, errors);
129
+ }
130
+
131
+ for (const [field, val] of [
132
+ ["if_true", s.if_true],
133
+ ["if_false", s.if_false],
134
+ ["then", s.then],
135
+ ]) {
136
+ if (val && !ids.has(val)) {
137
+ errors.push(`Step "${s.id}" references unknown step "${val}" in ${field}`);
138
+ }
139
+ }
140
+ for (const dep of s.requires ?? []) {
141
+ if (!ids.has(dep)) errors.push(`Step "${s.id}" references unknown step "${dep}" in requires`);
142
+ }
143
+ }
144
+
145
+ if (steps.length && hasRequiresCycle(steps, ids)) {
146
+ errors.push("Circular dependency detected in requires");
147
+ }
148
+
149
+ for (const orphan of findOrphans(steps, ids)) {
150
+ errors.push(`Step "${orphan}" is unreachable`);
151
+ }
152
+
153
+ return errors;
154
+ }
155
+
156
+ export async function runValidate(args) {
157
+ const fileArg = args.find((a) => !a.startsWith("-"));
158
+ const file = path.resolve(process.cwd(), fileArg || ".conductor/conductor.yaml");
159
+
160
+ if (!fs.existsSync(file)) {
161
+ console.error(red(`βœ— No conductor file at ${path.relative(process.cwd(), file)}`));
162
+ return false;
163
+ }
164
+
165
+ let doc;
166
+ try {
167
+ doc = yaml.load(fs.readFileSync(file, "utf8"));
168
+ } catch (e) {
169
+ console.error(red(`βœ— Could not parse YAML: ${e.message}`));
170
+ return false;
171
+ }
172
+
173
+ const errors = validateConductor(doc);
174
+ console.log("");
175
+ if (errors.length === 0) {
176
+ const steps = Array.isArray(doc.steps) ? doc.steps : [];
177
+ const acc = { soft: 0, hard: 0 };
178
+ countGates(steps, acc);
179
+ const conditions = steps.filter((s) => s.type === "condition").length;
180
+ const loops = steps.filter((s) => s.type === "loop").length;
181
+ const part = (n, one, many) => `${n} ${n === 1 ? one : many}`;
182
+ console.log(green(`βœ“ ${path.basename(file)} is valid`));
183
+ console.log(
184
+ dim(
185
+ ` ${part(steps.length, "step", "steps")}, ` +
186
+ `${part(acc.soft, "soft gate", "soft gates")}, ` +
187
+ `${part(acc.hard, "hard gate", "hard gates")}, ` +
188
+ `${part(conditions, "condition", "conditions")}, ` +
189
+ `${part(loops, "loop", "loops")}`,
190
+ ),
191
+ );
192
+ console.log("");
193
+ return true;
194
+ }
195
+
196
+ for (const e of errors) console.error(red(`βœ— ${e}`));
197
+ console.error("");
198
+ console.error(`${errors.length} error${errors.length === 1 ? "" : "s"} found`);
199
+ console.error("");
200
+ return false;
201
+ }