conductor-board 1.2.0 → 2.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/bin/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import readline from "node:readline";
5
6
  import { startServer } from "../server/server.js";
6
7
 
7
8
  const argv = process.argv.slice(2);
@@ -30,6 +31,21 @@ const HELP = `
30
31
  init Scaffold a new .conductor/conductor.yaml
31
32
  validate [path] Check a conductor against the spec
32
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
+ loop <loop> <item> <sub> <state> Update a loop sub-step
48
+ complete <id> [--attest-soft] Run hard gates independently, then advance
33
49
 
34
50
  Board options
35
51
  --path, -p <file> Path to status.json (default: .conductor/status.json)
@@ -73,6 +89,38 @@ if (command === "setup") {
73
89
  process.exit((await runSetup(rest)) ? 0 : 1);
74
90
  }
75
91
 
92
+ if (command === "check") {
93
+ const { runCheck } = await import("../cli/check.js");
94
+ process.exit((await runCheck(rest)) ? 0 : 1);
95
+ }
96
+
97
+ if (command === "ps") {
98
+ const { runPs } = await import("../cli/ps.js");
99
+ process.exit((await runPs()) ? 0 : 1);
100
+ }
101
+
102
+ if (command === "stop") {
103
+ const { runStop } = await import("../cli/stop.js");
104
+ process.exit((await runStop(rest)) ? 0 : 1);
105
+ }
106
+
107
+ if (command === "clean") {
108
+ const { runClean } = await import("../cli/clean.js");
109
+ process.exit((await runClean(rest)) ? 0 : 1);
110
+ }
111
+
112
+ // status-writer commands (for agents — keep the board live as you work)
113
+ if (["step", "gate", "heartbeat", "loop", "status-init"].includes(command)) {
114
+ const w = await import("../cli/writer.js");
115
+ const fn = { step: w.runStep, gate: w.runGate, heartbeat: w.runHeartbeat, loop: w.runLoop, "status-init": w.runStatusInit }[command];
116
+ process.exit((await fn(rest)) ? 0 : 1);
117
+ }
118
+
119
+ if (command === "complete") {
120
+ const { runComplete } = await import("../cli/complete.js");
121
+ process.exit((await runComplete(rest)) ? 0 : 1);
122
+ }
123
+
76
124
  if (command && command !== "board") {
77
125
  console.error(`Unknown command "${command}". Run with --help to see usage.`);
78
126
  process.exit(1);
@@ -117,6 +165,123 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
117
165
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
118
166
  const iris = (s) => `\x1b[38;5;141m${s}\x1b[0m`;
119
167
  const mint = (s) => `\x1b[38;5;78m${s}\x1b[0m`;
168
+ const amber = (s) => `\x1b[38;5;214m${s}\x1b[0m`;
169
+
170
+ function ago(iso) {
171
+ const t = new Date(iso).getTime();
172
+ if (Number.isNaN(t)) return "";
173
+ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
174
+ if (s < 60) return `${s}s ago`;
175
+ const m = Math.floor(s / 60);
176
+ if (m < 60) return `${m}m ago`;
177
+ const h = Math.floor(m / 60);
178
+ if (h < 24) return `${h}h ago`;
179
+ return `${Math.floor(h / 24)}d ago`;
180
+ }
181
+
182
+ function ask(question) {
183
+ return new Promise((resolve) => {
184
+ if (!process.stdin.isTTY) return resolve(false); // non-interactive — never block
185
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
186
+ rl.question(question, (answer) => {
187
+ rl.close();
188
+ resolve(/^y(es)?$/i.test(answer.trim()));
189
+ });
190
+ });
191
+ }
192
+
193
+ // A pid is "alive" if signalling it doesn't throw ESRCH (EPERM = alive, not ours).
194
+ function pidAlive(pid) {
195
+ try {
196
+ process.kill(pid, 0);
197
+ return true;
198
+ } catch (e) {
199
+ return e.code === "EPERM";
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Before binding a port, look for a board already serving this project.
205
+ *
206
+ * - dead pid / no file → clear any stale server.json, start fresh (returns null)
207
+ * - live board, non-interactive → REUSE it (returns its info). This is the key
208
+ * guard against the tab flood: re-running `npx conductor-board` no longer
209
+ * spawns a second server on the next port and opens another browser tab.
210
+ * - live board, interactive → ask. "y" kills it and starts fresh (null);
211
+ * anything else reuses the existing one (returns its info).
212
+ *
213
+ * One board per project — you should never end up with a pile of tabs.
214
+ */
215
+ async function preflightStaleBoard(serverJsonPath) {
216
+ let info;
217
+ try {
218
+ info = JSON.parse(fs.readFileSync(serverJsonPath, "utf8"));
219
+ } catch {
220
+ return null; // no (readable) server.json — nothing to do
221
+ }
222
+ const pid = info && info.pid;
223
+ const clear = () => {
224
+ try {
225
+ fs.unlinkSync(serverJsonPath);
226
+ } catch {
227
+ /* already gone */
228
+ }
229
+ };
230
+
231
+ if (!pid || !pidAlive(pid)) {
232
+ if (pid) {
233
+ clear();
234
+ console.log(dim(`\n cleared a stale server.json (pid ${pid} is no longer running).`));
235
+ }
236
+ return null;
237
+ }
238
+
239
+ const reuse = {
240
+ reuse: true,
241
+ pid,
242
+ url: info.url || `http://localhost:${info.port ?? "?"}`,
243
+ when: info.started_at ? ago(info.started_at) : "",
244
+ };
245
+
246
+ // Non-interactive (an agent, a script): never duplicate — reuse silently.
247
+ if (!process.stdin.isTTY) return reuse;
248
+
249
+ const port = info.port ?? "?";
250
+ const when = info.started_at ? `, started ${ago(info.started_at)}` : "";
251
+ console.log("");
252
+ console.log(` ${amber("⚠")} A board is already running on port ${bold(port)} (pid ${pid}${when}).`);
253
+ const yes = await ask(` Kill it and start a fresh one? ${dim("[y/N]")} `);
254
+ if (yes) {
255
+ try {
256
+ process.kill(pid, "SIGTERM");
257
+ } catch {
258
+ /* it may have just exited */
259
+ }
260
+ await new Promise((r) => setTimeout(r, 600)); // let it release the port
261
+ clear();
262
+ console.log(dim(` stopped pid ${pid}.`));
263
+ return null;
264
+ }
265
+ return reuse;
266
+ }
267
+
268
+ const preflightServerJson = path.join(
269
+ path.dirname(path.resolve(process.cwd(), statusPath)),
270
+ "server.json",
271
+ );
272
+ const existing = await preflightStaleBoard(preflightServerJson);
273
+ if (existing) {
274
+ console.log("");
275
+ console.log(` ${iris("🎼 conductor-board")}`);
276
+ console.log(
277
+ ` ${bold("Already live at")} ${mint(existing.url)} ${dim(
278
+ `— reusing it (pid ${existing.pid}${existing.when ? ", started " + existing.when : ""})`,
279
+ )}`,
280
+ );
281
+ console.log(` ${dim("one board per project — not opening another tab. stop that process to restart.")}`);
282
+ console.log("");
283
+ process.exit(0);
284
+ }
120
285
 
121
286
  const { conductorPath: resolvedConductor, absStatus, server, serverJsonPath } =
122
287
  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
+ }
@@ -0,0 +1,139 @@
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
+ const step = (doc.steps || []).find((s) => s && s.id === stepId);
67
+ if (!step) {
68
+ console.error(red(`✗ conductor has no step "${stepId}"`));
69
+ return false;
70
+ }
71
+
72
+ const soft = [];
73
+ const hard = [];
74
+ for (const g of step.gate || []) {
75
+ if (typeof g === "string") soft.push(g);
76
+ else if (g && typeof g.check === "string") hard.push({ name: g.name, check: g.check });
77
+ else if (g) soft.push(String(g));
78
+ }
79
+
80
+ console.log("");
81
+ const detail = [];
82
+ let allHardPass = true;
83
+
84
+ if (hard.length) {
85
+ console.log(" Hard gates:");
86
+ for (const h of hard) {
87
+ let passed = false;
88
+ let exitCode = 1;
89
+ try {
90
+ execSync(h.check, { stdio: "ignore", shell: "/bin/sh" });
91
+ passed = true;
92
+ exitCode = 0;
93
+ } catch (e) {
94
+ exitCode = typeof e.status === "number" ? e.status : 1;
95
+ }
96
+ allHardPass = allHardPass && passed;
97
+ console.log(
98
+ ` ${passed ? green("🔒 ✓") : red("🔒 ✕")} ${h.name || h.check} ${dim(`(exit ${exitCode})`)}`,
99
+ );
100
+ detail.push({ criterion: h.check, name: h.name, kind: "hard", passed, exit_code: exitCode, verified: true });
101
+ }
102
+ }
103
+
104
+ if (soft.length) {
105
+ console.log(" Soft gates:");
106
+ for (const s of soft) {
107
+ const attested = attestSoft;
108
+ console.log(` ${attested ? amber("✋ attested") : dim("✋ not attested")} "${s}"`);
109
+ detail.push({ criterion: s, kind: "soft", passed: attested ? true : null, verified: false });
110
+ }
111
+ }
112
+
113
+ const softOk = soft.length === 0 || attestSoft;
114
+ const ok = allHardPass && softOk;
115
+
116
+ console.log("");
117
+ if (ok) {
118
+ try {
119
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
120
+ const st = (status.steps[stepId] = status.steps[stepId] || { attempt: 1 });
121
+ st.status = "done";
122
+ st.gate = "passed";
123
+ st.gate_detail = detail;
124
+ st.completed_at = new Date().toISOString();
125
+ fs.writeFileSync(statusPath, JSON.stringify(status, null, 2));
126
+ } catch (e) {
127
+ console.error(red(`✗ gates passed but could not update status.json: ${e.message}`));
128
+ return false;
129
+ }
130
+ console.log(green(` ✅ All gates passed. Step ${stepId} → done.`));
131
+ console.log("");
132
+ return true;
133
+ }
134
+
135
+ if (!allHardPass) console.log(red(" ✕ Hard gate(s) failed — fix and retry. Step not advanced."));
136
+ else console.log(amber(" ✋ Soft gates not attested — re-run with --attest-soft once you've verified them."));
137
+ console.log("");
138
+ return false;
139
+ }
@@ -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
+ }
package/cli/setup.js CHANGED
@@ -27,11 +27,14 @@ steps:
27
27
 
28
28
  - id: start-board
29
29
  instruction: |
30
- Start the board server in the background. It opens the browser
31
- automatically — do NOT pass --no-open here, that defeats the live view.
30
+ Start the board server ONCE in the background and leave it running for the
31
+ whole run. It opens the browser automatically — do NOT pass --no-open here,
32
+ that defeats the live view.
32
33
  npx conductor-board >/tmp/conductor-board.log 2>&1 &
33
34
  Wait ~3 seconds for it to initialize. It auto-detects a free port if 3042
34
- is taken and records the chosen port in .conductor/server.json.
35
+ is taken and records the chosen port in .conductor/server.json. Do NOT run
36
+ this command again later — one board per run. Re-running just reuses the
37
+ live server, but repeatedly launching is how you end up with stray tabs.
35
38
  requires: [preflight]
36
39
  gate:
37
40
  - name: "Server config file exists"
@@ -77,16 +80,27 @@ steps:
77
80
  Create .conductor/status.json with all steps pending and a timestamp run_id.
78
81
  Set the top-level "goal" from the conductor's description, and refresh
79
82
  "current_step_goal" each time current_step changes.
80
- Walk each step in order, updating status.json after every step and gate
81
- change. Retry on gate failure never skip.
83
+ Walk each step in order, updating status.json after EVERY transition
84
+ (pending -> running -> gate checking -> passed/failed -> done). The human is
85
+ watching the board to follow along — never do real work without updating the
86
+ board first. Doing work the board doesn't reflect ("freeballing") is not
87
+ allowed: if you drift, stop, re-sync the board, restart the step cleanly, and
88
+ apologize. The board shows a red "Freeballing?" banner after ~3 minutes
89
+ without a heartbeat. Retry on gate failure — never skip.
82
90
  At least once per minute, append a heartbeat {at, note} to the current
83
91
  step's heartbeat array (read prior entries first; orient against the gate
84
92
  AND the goal; use [text](url) links for any PRs or pages you produce).
93
+ Before marking each step done, append a finalBeat — {at, note, finalBeat:
94
+ true, handoff: {to, context, produced}} — summarizing the step and handing
95
+ off to the next; read the previous step's finalBeat before you start one.
85
96
  For loop steps, update "completed" and the "iterations" object as EACH
86
97
  iteration finishes — don't wait until the loop ends.
98
+ At the START of the run, read .conductor/insights.md (if it exists) to carry
99
+ forward what past runs learned — don't repeat insights already recorded there.
87
100
  Tag heartbeats with an "insight" object when you spot a way to improve the
88
101
  workflow. Before setting status to "done", review your insight-tagged
89
- heartbeats and write 3-5 optimization "suggestions" to status.json.
102
+ heartbeats and write 3-5 optimization "suggestions" to status.json — the board
103
+ merges them into the persistent .conductor/insights.md ledger for next time.
90
104
  Set the top-level status to "done" when the last step completes.
91
105
  requires: [convert-to-conductor]
92
106
  gate: