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 +21 -0
- package/README.md +118 -0
- package/bin/cli.js +150 -0
- package/cli/init.js +87 -0
- package/cli/setup.js +105 -0
- package/cli/validate.js +201 -0
- package/dist/assets/index--J1uxrSo.js +34 -0
- package/dist/assets/index-DMqA9hDY.css +1 -0
- package/dist/assets/motion-Dmvx5jlk.js +25 -0
- package/dist/assets/yaml-NA7d4LV6.js +32 -0
- package/dist/conductor.svg +14 -0
- package/dist/index.html +17 -0
- package/package.json +63 -0
- package/server/server.js +349 -0
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
|
+
}
|
package/cli/validate.js
ADDED
|
@@ -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
|
+
}
|