conductor-board 1.3.0 → 2.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.
- package/bin/cli.js +101 -12
- package/cli/check.js +89 -0
- package/cli/clean.js +121 -0
- package/cli/complete.js +169 -0
- package/cli/discover.js +42 -0
- package/cli/ps.js +38 -0
- package/cli/setup.js +19 -6
- package/cli/stop.js +71 -0
- package/cli/validate.js +22 -3
- package/cli/writer.js +218 -0
- package/dist/assets/index-D0azgMCk.css +1 -0
- package/dist/assets/index-DV9PNUB3.js +34 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/server.js +277 -12
- package/dist/assets/index-Dauby4vm.js +0 -34
- package/dist/assets/index-nvl4ljRP.css +0 -1
package/bin/cli.js
CHANGED
|
@@ -31,13 +31,29 @@ const HELP = `
|
|
|
31
31
|
init Scaffold a new .conductor/conductor.yaml
|
|
32
32
|
validate [path] Check a conductor against the spec
|
|
33
33
|
setup Write setup.conductor.yaml (the bootstrap conductor)
|
|
34
|
+
check <step-id> Board-sync pre-gate check — fails if the board is
|
|
35
|
+
stale for this step (use as a step's first gate)
|
|
36
|
+
ps List every conductor-board running on this machine
|
|
37
|
+
stop [--all] Stop this project's board (or every board)
|
|
38
|
+
clean [--keep N] Trim history to the last N runs; add
|
|
39
|
+
[--prune-heartbeats] to archive old beats (keeps finalBeats + insights)
|
|
40
|
+
|
|
41
|
+
Status-writer (for agents — keep status.json live as you work)
|
|
42
|
+
status-init <file> Generate .conductor/status.json from a conductor
|
|
43
|
+
step <id> <state> running | done | failed (state)
|
|
44
|
+
gate <id> <state> checking | passed | failed
|
|
45
|
+
heartbeat <id> "note" Append a heartbeat (--iteration, --insight-type,
|
|
46
|
+
--insight-seed, --final, --to <step>)
|
|
47
|
+
suggest "title" Add a post-run suggestion (--type, --step, --rationale)
|
|
48
|
+
loop <loop> <item> <sub> <state> Update a loop sub-step
|
|
49
|
+
complete <step>[::iter::sub] [--attest-soft] Run hard gates, then advance
|
|
34
50
|
|
|
35
51
|
Board options
|
|
36
52
|
--path, -p <file> Path to status.json (default: .conductor/status.json)
|
|
37
53
|
--conductor, -c <file> Path to the conductor (default: auto-discovered)
|
|
38
54
|
--port <n> Port to serve on (default: 3042)
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
(the board always opens your browser — set
|
|
56
|
+
CONDUCTOR_NO_OPEN=1 to suppress in CI/headless)
|
|
41
57
|
|
|
42
58
|
init options
|
|
43
59
|
--name, -n <name> Workflow name (skips the prompts)
|
|
@@ -74,6 +90,45 @@ if (command === "setup") {
|
|
|
74
90
|
process.exit((await runSetup(rest)) ? 0 : 1);
|
|
75
91
|
}
|
|
76
92
|
|
|
93
|
+
if (command === "check") {
|
|
94
|
+
const { runCheck } = await import("../cli/check.js");
|
|
95
|
+
process.exit((await runCheck(rest)) ? 0 : 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (command === "ps") {
|
|
99
|
+
const { runPs } = await import("../cli/ps.js");
|
|
100
|
+
process.exit((await runPs()) ? 0 : 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (command === "stop") {
|
|
104
|
+
const { runStop } = await import("../cli/stop.js");
|
|
105
|
+
process.exit((await runStop(rest)) ? 0 : 1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (command === "clean") {
|
|
109
|
+
const { runClean } = await import("../cli/clean.js");
|
|
110
|
+
process.exit((await runClean(rest)) ? 0 : 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// status-writer commands (for agents — keep the board live as you work)
|
|
114
|
+
if (["step", "gate", "heartbeat", "loop", "status-init", "suggest"].includes(command)) {
|
|
115
|
+
const w = await import("../cli/writer.js");
|
|
116
|
+
const fn = {
|
|
117
|
+
step: w.runStep,
|
|
118
|
+
gate: w.runGate,
|
|
119
|
+
heartbeat: w.runHeartbeat,
|
|
120
|
+
loop: w.runLoop,
|
|
121
|
+
"status-init": w.runStatusInit,
|
|
122
|
+
suggest: w.runSuggest,
|
|
123
|
+
}[command];
|
|
124
|
+
process.exit((await fn(rest)) ? 0 : 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (command === "complete") {
|
|
128
|
+
const { runComplete } = await import("../cli/complete.js");
|
|
129
|
+
process.exit((await runComplete(rest)) ? 0 : 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
77
132
|
if (command && command !== "board") {
|
|
78
133
|
console.error(`Unknown command "${command}". Run with --help to see usage.`);
|
|
79
134
|
process.exit(1);
|
|
@@ -84,7 +139,9 @@ const statusPath = String(flag(["--path", "-p"], ".conductor/status.json"));
|
|
|
84
139
|
const conductorArg = flag(["--conductor", "-c"], null);
|
|
85
140
|
const conductorPath = conductorArg ? path.resolve(process.cwd(), String(conductorArg)) : null;
|
|
86
141
|
const wantedPort = Number(flag(["--port"], 3042)) || 3042;
|
|
87
|
-
|
|
142
|
+
// The board always opens the browser — it's meant to be seen. CI/headless can
|
|
143
|
+
// suppress at the OS level via the CONDUCTOR_NO_OPEN env var (no --no-open flag).
|
|
144
|
+
const noOpen = process.env.CONDUCTOR_NO_OPEN === "1";
|
|
88
145
|
|
|
89
146
|
function openBrowser(url) {
|
|
90
147
|
const cmd =
|
|
@@ -154,15 +211,23 @@ function pidAlive(pid) {
|
|
|
154
211
|
}
|
|
155
212
|
|
|
156
213
|
/**
|
|
157
|
-
* Before binding a port, look for a board
|
|
158
|
-
*
|
|
214
|
+
* Before binding a port, look for a board already serving this project.
|
|
215
|
+
*
|
|
216
|
+
* - dead pid / no file → clear any stale server.json, start fresh (returns null)
|
|
217
|
+
* - live board, non-interactive → REUSE it (returns its info). This is the key
|
|
218
|
+
* guard against the tab flood: re-running `npx conductor-board` no longer
|
|
219
|
+
* spawns a second server on the next port and opens another browser tab.
|
|
220
|
+
* - live board, interactive → ask. "y" kills it and starts fresh (null);
|
|
221
|
+
* anything else reuses the existing one (returns its info).
|
|
222
|
+
*
|
|
223
|
+
* One board per project — you should never end up with a pile of tabs.
|
|
159
224
|
*/
|
|
160
225
|
async function preflightStaleBoard(serverJsonPath) {
|
|
161
226
|
let info;
|
|
162
227
|
try {
|
|
163
228
|
info = JSON.parse(fs.readFileSync(serverJsonPath, "utf8"));
|
|
164
229
|
} catch {
|
|
165
|
-
return; // no (readable) server.json — nothing to do
|
|
230
|
+
return null; // no (readable) server.json — nothing to do
|
|
166
231
|
}
|
|
167
232
|
const pid = info && info.pid;
|
|
168
233
|
const clear = () => {
|
|
@@ -178,14 +243,24 @@ async function preflightStaleBoard(serverJsonPath) {
|
|
|
178
243
|
clear();
|
|
179
244
|
console.log(dim(`\n cleared a stale server.json (pid ${pid} is no longer running).`));
|
|
180
245
|
}
|
|
181
|
-
return;
|
|
246
|
+
return null;
|
|
182
247
|
}
|
|
183
248
|
|
|
249
|
+
const reuse = {
|
|
250
|
+
reuse: true,
|
|
251
|
+
pid,
|
|
252
|
+
url: info.url || `http://localhost:${info.port ?? "?"}`,
|
|
253
|
+
when: info.started_at ? ago(info.started_at) : "",
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Non-interactive (an agent, a script): never duplicate — reuse silently.
|
|
257
|
+
if (!process.stdin.isTTY) return reuse;
|
|
258
|
+
|
|
184
259
|
const port = info.port ?? "?";
|
|
185
260
|
const when = info.started_at ? `, started ${ago(info.started_at)}` : "";
|
|
186
261
|
console.log("");
|
|
187
262
|
console.log(` ${amber("⚠")} A board is already running on port ${bold(port)} (pid ${pid}${when}).`);
|
|
188
|
-
const yes = await ask(` Kill it and start fresh? ${dim("[y/N]")} `);
|
|
263
|
+
const yes = await ask(` Kill it and start a fresh one? ${dim("[y/N]")} `);
|
|
189
264
|
if (yes) {
|
|
190
265
|
try {
|
|
191
266
|
process.kill(pid, "SIGTERM");
|
|
@@ -195,14 +270,28 @@ async function preflightStaleBoard(serverJsonPath) {
|
|
|
195
270
|
await new Promise((r) => setTimeout(r, 600)); // let it release the port
|
|
196
271
|
clear();
|
|
197
272
|
console.log(dim(` stopped pid ${pid}.`));
|
|
198
|
-
|
|
199
|
-
console.log(dim(` leaving it running — starting on the next free port.`));
|
|
273
|
+
return null;
|
|
200
274
|
}
|
|
275
|
+
return reuse;
|
|
201
276
|
}
|
|
202
277
|
|
|
203
|
-
|
|
204
|
-
path.
|
|
278
|
+
const preflightServerJson = path.join(
|
|
279
|
+
path.dirname(path.resolve(process.cwd(), statusPath)),
|
|
280
|
+
"server.json",
|
|
205
281
|
);
|
|
282
|
+
const existing = await preflightStaleBoard(preflightServerJson);
|
|
283
|
+
if (existing) {
|
|
284
|
+
console.log("");
|
|
285
|
+
console.log(` ${iris("🎼 conductor-board")}`);
|
|
286
|
+
console.log(
|
|
287
|
+
` ${bold("Already live at")} ${mint(existing.url)} ${dim(
|
|
288
|
+
`— reusing it (pid ${existing.pid}${existing.when ? ", started " + existing.when : ""})`,
|
|
289
|
+
)}`,
|
|
290
|
+
);
|
|
291
|
+
console.log(` ${dim("one board per project — not opening another tab. stop that process to restart.")}`);
|
|
292
|
+
console.log("");
|
|
293
|
+
process.exit(0);
|
|
294
|
+
}
|
|
206
295
|
|
|
207
296
|
const { conductorPath: resolvedConductor, absStatus, server, serverJsonPath } =
|
|
208
297
|
await listenWithFallback(wantedPort);
|
package/cli/check.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
5
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
6
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
7
|
+
|
|
8
|
+
const STALE_MS = 5 * 60 * 1000; // 5 minutes of silence ⇒ the board is stale
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Board-sync pre-check (spec §8.1). Run as the FIRST gate criterion on every
|
|
12
|
+
* step — `check: "npx conductor-board check <step-id>"` — so an agent that does
|
|
13
|
+
* work without keeping the board current literally fails its own gate.
|
|
14
|
+
*
|
|
15
|
+
* Passes only when, for <step-id>:
|
|
16
|
+
* 1. status.json's current_step matches it (it's marked running);
|
|
17
|
+
* 2. it has at least one heartbeat;
|
|
18
|
+
* 3. its most recent heartbeat is within the last 5 minutes (not stale).
|
|
19
|
+
*
|
|
20
|
+
* (We check the latest heartbeat's age, not started_at — a legitimately long
|
|
21
|
+
* step that keeps beating is fine; only silence means the board has gone stale.)
|
|
22
|
+
*/
|
|
23
|
+
export async function runCheck(args) {
|
|
24
|
+
const flagIdx = (names) => {
|
|
25
|
+
for (const n of names) {
|
|
26
|
+
const i = args.indexOf(n);
|
|
27
|
+
if (i !== -1) return i;
|
|
28
|
+
}
|
|
29
|
+
return -1;
|
|
30
|
+
};
|
|
31
|
+
const pi = flagIdx(["--path", "-p"]);
|
|
32
|
+
const statusPath = path.resolve(
|
|
33
|
+
process.cwd(),
|
|
34
|
+
pi !== -1 && args[pi + 1] ? args[pi + 1] : ".conductor/status.json",
|
|
35
|
+
);
|
|
36
|
+
const stepId = args.find((a) => !a.startsWith("-"));
|
|
37
|
+
|
|
38
|
+
const fail = (msg) => {
|
|
39
|
+
console.error(`${red("✗ board-sync")} ${msg}`);
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!stepId) return fail("usage: conductor-board check <step-id> [--path status.json]");
|
|
44
|
+
|
|
45
|
+
let status;
|
|
46
|
+
try {
|
|
47
|
+
status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
48
|
+
} catch {
|
|
49
|
+
return fail(
|
|
50
|
+
`could not read ${path.relative(process.cwd(), statusPath)} — start the board and write status.json first.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const step = status.steps?.[stepId];
|
|
55
|
+
if (!step) return fail(`status.json has no step "${stepId}". Write the step before gating it.`);
|
|
56
|
+
|
|
57
|
+
if (status.current_step !== stepId) {
|
|
58
|
+
return fail(
|
|
59
|
+
`current_step is "${status.current_step ?? "—"}", not "${stepId}". Mark this step running on the board before its gate.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const beats = Array.isArray(step.heartbeat) ? step.heartbeat : [];
|
|
64
|
+
if (beats.length === 0) {
|
|
65
|
+
return fail(`step "${stepId}" has no heartbeats. Heartbeat as you work — the gate can't pass on a silent step.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lastAt = beats
|
|
69
|
+
.map((h) => (h && typeof h.at === "string" ? new Date(h.at).getTime() : NaN))
|
|
70
|
+
.filter((t) => Number.isFinite(t))
|
|
71
|
+
.sort((a, b) => b - a)[0];
|
|
72
|
+
if (!Number.isFinite(lastAt)) {
|
|
73
|
+
return fail(`step "${stepId}" has heartbeats but none with a valid timestamp.`);
|
|
74
|
+
}
|
|
75
|
+
const age = Date.now() - lastAt;
|
|
76
|
+
if (age > STALE_MS) {
|
|
77
|
+
return fail(
|
|
78
|
+
`step "${stepId}" last beat was ${Math.round(age / 60000)}m ago — the board is stale. ` +
|
|
79
|
+
`Re-sync status.json to reality and restart the step; don't back-fill (that's freeballing).`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(
|
|
84
|
+
`${green("✓ board-sync")} "${stepId}" ${dim(
|
|
85
|
+
`— current, ${beats.length} heartbeat(s), last ${Math.round(age / 1000)}s ago`,
|
|
86
|
+
)}`,
|
|
87
|
+
);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
package/cli/clean.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
5
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
6
|
+
const amber = (s) => `\x1b[38;5;214m${s}\x1b[0m`;
|
|
7
|
+
|
|
8
|
+
const flag = (args, names, def) => {
|
|
9
|
+
for (const n of names) {
|
|
10
|
+
const i = args.indexOf(n);
|
|
11
|
+
if (i !== -1) {
|
|
12
|
+
const v = args[i + 1];
|
|
13
|
+
return v && !v.startsWith("-") ? v : true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return def;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Workflow directories under a .conductor root (flat + subdirs). */
|
|
20
|
+
function workflowDirs(root) {
|
|
21
|
+
const dirs = [];
|
|
22
|
+
const has = (d) =>
|
|
23
|
+
fs.existsSync(path.join(d, "status.json")) || fs.existsSync(path.join(d, "history"));
|
|
24
|
+
if (has(root)) dirs.push(root);
|
|
25
|
+
try {
|
|
26
|
+
for (const e of fs.readdirSync(root, { withFileTypes: true })) {
|
|
27
|
+
if (e.isDirectory() && e.name !== "history") {
|
|
28
|
+
const d = path.join(root, e.name);
|
|
29
|
+
if (has(d)) dirs.push(d);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
/* no root */
|
|
34
|
+
}
|
|
35
|
+
return dirs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** conductor-board clean [--keep N] [--prune-heartbeats [--keep-beats M]] [--dry-run] */
|
|
39
|
+
export async function runClean(args) {
|
|
40
|
+
const root = path.resolve(process.cwd(), String(flag(args, ["--dir"], ".conductor")));
|
|
41
|
+
const keep = Number(flag(args, ["--keep"], 50)) || 50;
|
|
42
|
+
const keepBeats = Number(flag(args, ["--keep-beats"], 30)) || 30;
|
|
43
|
+
const pruneBeats = args.includes("--prune-heartbeats");
|
|
44
|
+
const dry = args.includes("--dry-run");
|
|
45
|
+
|
|
46
|
+
const dirs = workflowDirs(root);
|
|
47
|
+
if (dirs.length === 0) {
|
|
48
|
+
console.log(dim(`\n nothing to clean under ${path.relative(process.cwd(), root) || root}\n`));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log("");
|
|
53
|
+
if (dry) console.log(amber(" DRY RUN — nothing will be deleted or written.\n"));
|
|
54
|
+
let runsDeleted = 0;
|
|
55
|
+
let beatsArchived = 0;
|
|
56
|
+
|
|
57
|
+
for (const dir of dirs) {
|
|
58
|
+
const name = path.basename(dir);
|
|
59
|
+
|
|
60
|
+
// 1 — trim history to the last `keep` runs
|
|
61
|
+
const histDir = path.join(dir, "history");
|
|
62
|
+
try {
|
|
63
|
+
const files = fs
|
|
64
|
+
.readdirSync(histDir)
|
|
65
|
+
.filter((f) => f.endsWith(".json"))
|
|
66
|
+
.map((f) => ({ f, t: fs.statSync(path.join(histDir, f)).mtimeMs }))
|
|
67
|
+
.sort((a, b) => b.t - a.t);
|
|
68
|
+
const old = files.slice(keep);
|
|
69
|
+
for (const { f } of old) {
|
|
70
|
+
if (!dry) fs.unlinkSync(path.join(histDir, f));
|
|
71
|
+
runsDeleted += 1;
|
|
72
|
+
}
|
|
73
|
+
if (old.length) console.log(` ${name}: ${dry ? "would remove" : "removed"} ${old.length} old run(s) (keep ${keep})`);
|
|
74
|
+
} catch {
|
|
75
|
+
/* no history */
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2 — prune heartbeats (opt-in), keeping finalBeats + insight beats
|
|
79
|
+
if (pruneBeats) {
|
|
80
|
+
const statusPath = path.join(dir, "status.json");
|
|
81
|
+
try {
|
|
82
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
83
|
+
const overflow = [];
|
|
84
|
+
for (const [stepId, step] of Object.entries(status.steps || {})) {
|
|
85
|
+
const hb = Array.isArray(step.heartbeat) ? step.heartbeat : [];
|
|
86
|
+
const protectedSet = new Set(hb.filter((b) => b.finalBeat || b.insight));
|
|
87
|
+
const regular = hb.filter((b) => !protectedSet.has(b));
|
|
88
|
+
if (regular.length <= keepBeats) continue;
|
|
89
|
+
const drop = new Set(regular.slice(0, regular.length - keepBeats));
|
|
90
|
+
for (const b of hb) if (drop.has(b)) overflow.push({ step: stepId, ...b });
|
|
91
|
+
step.heartbeat = hb.filter((b) => !drop.has(b));
|
|
92
|
+
}
|
|
93
|
+
if (overflow.length) {
|
|
94
|
+
beatsArchived += overflow.length;
|
|
95
|
+
if (!dry) {
|
|
96
|
+
fs.appendFileSync(
|
|
97
|
+
path.join(dir, "heartbeat-archive.jsonl"),
|
|
98
|
+
overflow.map((o) => JSON.stringify(o)).join("\n") + "\n",
|
|
99
|
+
);
|
|
100
|
+
fs.writeFileSync(statusPath, JSON.stringify(status, null, 2));
|
|
101
|
+
}
|
|
102
|
+
console.log(
|
|
103
|
+
` ${name}: ${dry ? "would archive" : "archived"} ${overflow.length} heartbeat(s) (keep ${keepBeats}/step; finalBeats + insights kept)`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
/* no status */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log(
|
|
114
|
+
green(
|
|
115
|
+
` ${dry ? "would clean" : "cleaned"}: ${runsDeleted} run(s)` +
|
|
116
|
+
(pruneBeats ? `, ${beatsArchived} heartbeat(s) archived` : ""),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
console.log("");
|
|
120
|
+
return true;
|
|
121
|
+
}
|
package/cli/complete.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
|
|
6
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
7
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
8
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
9
|
+
const amber = (s) => `\x1b[38;5;214m${s}\x1b[0m`;
|
|
10
|
+
|
|
11
|
+
function flag(args, names) {
|
|
12
|
+
for (const n of names) {
|
|
13
|
+
const i = args.indexOf(n);
|
|
14
|
+
if (i !== -1) {
|
|
15
|
+
const v = args[i + 1];
|
|
16
|
+
return v && !v.startsWith("-") ? v : true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function discoverConductor(statusPath, explicit) {
|
|
23
|
+
if (explicit) return path.resolve(process.cwd(), explicit);
|
|
24
|
+
const dir = path.dirname(statusPath);
|
|
25
|
+
for (const c of ["conductor.yaml", "conductor.yml"]) {
|
|
26
|
+
const p = path.join(dir, c);
|
|
27
|
+
if (fs.existsSync(p)) return p;
|
|
28
|
+
}
|
|
29
|
+
for (const c of ["conductor.yaml", "conductor.yml"]) {
|
|
30
|
+
const p = path.resolve(process.cwd(), c);
|
|
31
|
+
if (fs.existsSync(p)) return p;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* conductor-board complete <step-id> [--attest-soft]
|
|
38
|
+
*
|
|
39
|
+
* Runs the step's HARD gates independently (the agent can't fake the result),
|
|
40
|
+
* then — if all hard gates pass and soft gates are attested — marks the step done
|
|
41
|
+
* with gate_detail tagging each criterion 🔒 verified (CLI ran it) or ✋ attested.
|
|
42
|
+
*/
|
|
43
|
+
export async function runComplete(args) {
|
|
44
|
+
const p = flag(args, ["--path", "-p"]);
|
|
45
|
+
const statusPath = path.resolve(process.cwd(), typeof p === "string" ? p : ".conductor/status.json");
|
|
46
|
+
const stepId = args.find((a) => !a.startsWith("-"));
|
|
47
|
+
const attestSoft = args.includes("--attest-soft");
|
|
48
|
+
|
|
49
|
+
if (!stepId) {
|
|
50
|
+
console.error(red("usage: conductor-board complete <step-id> [--attest-soft]"));
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const conductorPath = discoverConductor(statusPath, flag(args, ["--conductor", "-c"]));
|
|
55
|
+
if (!conductorPath) {
|
|
56
|
+
console.error(red("✗ no conductor file found next to status.json or in cwd"));
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
let doc;
|
|
60
|
+
try {
|
|
61
|
+
doc = yaml.load(fs.readFileSync(conductorPath, "utf8"));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(red(`✗ could not parse conductor: ${e.message}`));
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
// resolve the step — either a top-level id, or a loop sub-step "loop::iter::sub"
|
|
67
|
+
const parts = stepId.split("::");
|
|
68
|
+
let step;
|
|
69
|
+
let loopPath = null;
|
|
70
|
+
if (parts.length === 3) {
|
|
71
|
+
const [loopId, iter, subId] = parts;
|
|
72
|
+
const loopStep = (doc.steps || []).find((s) => s && s.id === loopId && s.type === "loop");
|
|
73
|
+
if (!loopStep) {
|
|
74
|
+
console.error(red(`✗ conductor has no loop "${loopId}"`));
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
step = (loopStep.steps || []).find((s) => s && s.id === subId);
|
|
78
|
+
if (!step) {
|
|
79
|
+
console.error(red(`✗ loop "${loopId}" has no sub-step "${subId}"`));
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
loopPath = { loopId, iter, subId };
|
|
83
|
+
} else {
|
|
84
|
+
step = (doc.steps || []).find((s) => s && s.id === stepId);
|
|
85
|
+
if (!step) {
|
|
86
|
+
console.error(red(`✗ conductor has no step "${stepId}"`));
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const soft = [];
|
|
92
|
+
const hard = [];
|
|
93
|
+
for (const g of step.gate || []) {
|
|
94
|
+
if (typeof g === "string") soft.push(g);
|
|
95
|
+
else if (g && typeof g.check === "string") hard.push({ name: g.name, check: g.check });
|
|
96
|
+
else if (g) soft.push(String(g));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log("");
|
|
100
|
+
const detail = [];
|
|
101
|
+
let allHardPass = true;
|
|
102
|
+
|
|
103
|
+
if (hard.length) {
|
|
104
|
+
console.log(" Hard gates:");
|
|
105
|
+
for (const h of hard) {
|
|
106
|
+
let passed = false;
|
|
107
|
+
let exitCode = 1;
|
|
108
|
+
try {
|
|
109
|
+
execSync(h.check, { stdio: "ignore", shell: "/bin/sh" });
|
|
110
|
+
passed = true;
|
|
111
|
+
exitCode = 0;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
exitCode = typeof e.status === "number" ? e.status : 1;
|
|
114
|
+
}
|
|
115
|
+
allHardPass = allHardPass && passed;
|
|
116
|
+
console.log(
|
|
117
|
+
` ${passed ? green("🔒 ✓") : red("🔒 ✕")} ${h.name || h.check} ${dim(`(exit ${exitCode})`)}`,
|
|
118
|
+
);
|
|
119
|
+
detail.push({ criterion: h.check, name: h.name, kind: "hard", passed, exit_code: exitCode, verified: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (soft.length) {
|
|
124
|
+
console.log(" Soft gates:");
|
|
125
|
+
for (const s of soft) {
|
|
126
|
+
const attested = attestSoft;
|
|
127
|
+
console.log(` ${attested ? amber("✋ attested") : dim("✋ not attested")} "${s}"`);
|
|
128
|
+
detail.push({ criterion: s, kind: "soft", passed: attested ? true : null, verified: false });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const softOk = soft.length === 0 || attestSoft;
|
|
133
|
+
const ok = allHardPass && softOk;
|
|
134
|
+
|
|
135
|
+
console.log("");
|
|
136
|
+
if (ok) {
|
|
137
|
+
try {
|
|
138
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
139
|
+
let st;
|
|
140
|
+
if (loopPath) {
|
|
141
|
+
const lp = (status.steps[loopPath.loopId] = status.steps[loopPath.loopId] || {
|
|
142
|
+
type: "loop",
|
|
143
|
+
iterations: {},
|
|
144
|
+
});
|
|
145
|
+
lp.iterations = lp.iterations || {};
|
|
146
|
+
const it = (lp.iterations[loopPath.iter] = lp.iterations[loopPath.iter] || {});
|
|
147
|
+
st = it[loopPath.subId] = it[loopPath.subId] || { attempt: 1 };
|
|
148
|
+
} else {
|
|
149
|
+
st = status.steps[stepId] = status.steps[stepId] || { attempt: 1 };
|
|
150
|
+
}
|
|
151
|
+
st.status = "done";
|
|
152
|
+
st.gate = "passed";
|
|
153
|
+
st.gate_detail = detail;
|
|
154
|
+
st.completed_at = new Date().toISOString();
|
|
155
|
+
fs.writeFileSync(statusPath, JSON.stringify(status, null, 2));
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error(red(`✗ gates passed but could not update status.json: ${e.message}`));
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
console.log(green(` ✅ All gates passed. Step ${stepId} → done.`));
|
|
161
|
+
console.log("");
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!allHardPass) console.log(red(" ✕ Hard gate(s) failed — fix and retry. Step not advanced."));
|
|
166
|
+
else console.log(amber(" ✋ Soft gates not attested — re-run with --attest-soft once you've verified them."));
|
|
167
|
+
console.log("");
|
|
168
|
+
return false;
|
|
169
|
+
}
|
package/cli/discover.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
|
|
3
|
+
/** GET /health on a local port; resolves the parsed health or null. */
|
|
4
|
+
export function getHealth(port, timeout = 400) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const req = http.get(
|
|
7
|
+
{ host: "127.0.0.1", port, path: "/health", timeout },
|
|
8
|
+
(res) => {
|
|
9
|
+
let data = "";
|
|
10
|
+
res.on("data", (c) => (data += c));
|
|
11
|
+
res.on("end", () => {
|
|
12
|
+
try {
|
|
13
|
+
const h = JSON.parse(data);
|
|
14
|
+
resolve(h && h.status === "ok" ? { port, ...h } : null);
|
|
15
|
+
} catch {
|
|
16
|
+
resolve(null);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
req.on("error", () => resolve(null));
|
|
22
|
+
req.on("timeout", () => {
|
|
23
|
+
req.destroy();
|
|
24
|
+
resolve(null);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Scan the conductor-board port range for live boards. */
|
|
30
|
+
export async function scanBoards(from = 3042, to = 3099) {
|
|
31
|
+
const ports = [];
|
|
32
|
+
for (let p = from; p <= to; p++) ports.push(p);
|
|
33
|
+
const all = await Promise.all(ports.map((p) => getHealth(p)));
|
|
34
|
+
return all.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function fmtUptime(s) {
|
|
38
|
+
if (!Number.isFinite(s)) return "—";
|
|
39
|
+
const h = Math.floor(s / 3600);
|
|
40
|
+
const m = Math.floor((s % 3600) / 60);
|
|
41
|
+
return `${h}h ${m}m`;
|
|
42
|
+
}
|
package/cli/ps.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { scanBoards, fmtUptime } from "./discover.js";
|
|
2
|
+
|
|
3
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
4
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
5
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
6
|
+
|
|
7
|
+
/** List every conductor-board server running on this machine. */
|
|
8
|
+
export async function runPs() {
|
|
9
|
+
const boards = await scanBoards();
|
|
10
|
+
console.log("");
|
|
11
|
+
if (boards.length === 0) {
|
|
12
|
+
console.log(dim(" No conductor-board servers running."));
|
|
13
|
+
console.log("");
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(" " + bold(pad("PID", 7) + pad("PORT", 7) + pad("UPTIME", 10) + pad("MEM", 8) + "WORKFLOWS"));
|
|
18
|
+
let totalMem = 0;
|
|
19
|
+
for (const h of boards.sort((a, b) => a.port - b.port)) {
|
|
20
|
+
totalMem += h.memory_mb || 0;
|
|
21
|
+
const wfs = Object.entries(h.workflows || {})
|
|
22
|
+
.map(([n, s]) => `${n} (${s.status})`)
|
|
23
|
+
.join(", ");
|
|
24
|
+
console.log(
|
|
25
|
+
" " +
|
|
26
|
+
pad(h.pid ?? "?", 7) +
|
|
27
|
+
pad(h.port, 7) +
|
|
28
|
+
pad(fmtUptime(h.uptime_seconds), 10) +
|
|
29
|
+
pad(`${h.memory_mb ?? "?"} MB`, 8) +
|
|
30
|
+
(wfs || dim("—")),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
console.log("");
|
|
34
|
+
console.log(dim(` ${boards.length} board${boards.length === 1 ? "" : "s"} running, ${totalMem} MB total`));
|
|
35
|
+
console.log(dim(" stop one with conductor-board stop · all with conductor-board stop --all"));
|
|
36
|
+
console.log("");
|
|
37
|
+
return true;
|
|
38
|
+
}
|