conductor-board 1.3.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
@@ -31,6 +31,21 @@ 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
+ loop <loop> <item> <sub> <state> Update a loop sub-step
48
+ complete <id> [--attest-soft] Run hard gates independently, then advance
34
49
 
35
50
  Board options
36
51
  --path, -p <file> Path to status.json (default: .conductor/status.json)
@@ -74,6 +89,38 @@ if (command === "setup") {
74
89
  process.exit((await runSetup(rest)) ? 0 : 1);
75
90
  }
76
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
+
77
124
  if (command && command !== "board") {
78
125
  console.error(`Unknown command "${command}". Run with --help to see usage.`);
79
126
  process.exit(1);
@@ -154,15 +201,23 @@ function pidAlive(pid) {
154
201
  }
155
202
 
156
203
  /**
157
- * Before binding a port, look for a board that's already running here. A live
158
- * one we offer to reclaim; a dead pid leaves a stale server.json we just clear.
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.
159
214
  */
160
215
  async function preflightStaleBoard(serverJsonPath) {
161
216
  let info;
162
217
  try {
163
218
  info = JSON.parse(fs.readFileSync(serverJsonPath, "utf8"));
164
219
  } catch {
165
- return; // no (readable) server.json — nothing to do
220
+ return null; // no (readable) server.json — nothing to do
166
221
  }
167
222
  const pid = info && info.pid;
168
223
  const clear = () => {
@@ -178,14 +233,24 @@ async function preflightStaleBoard(serverJsonPath) {
178
233
  clear();
179
234
  console.log(dim(`\n cleared a stale server.json (pid ${pid} is no longer running).`));
180
235
  }
181
- return;
236
+ return null;
182
237
  }
183
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
+
184
249
  const port = info.port ?? "?";
185
250
  const when = info.started_at ? `, started ${ago(info.started_at)}` : "";
186
251
  console.log("");
187
252
  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]")} `);
253
+ const yes = await ask(` Kill it and start a fresh one? ${dim("[y/N]")} `);
189
254
  if (yes) {
190
255
  try {
191
256
  process.kill(pid, "SIGTERM");
@@ -195,14 +260,28 @@ async function preflightStaleBoard(serverJsonPath) {
195
260
  await new Promise((r) => setTimeout(r, 600)); // let it release the port
196
261
  clear();
197
262
  console.log(dim(` stopped pid ${pid}.`));
198
- } else {
199
- console.log(dim(` leaving it running — starting on the next free port.`));
263
+ return null;
200
264
  }
265
+ return reuse;
201
266
  }
202
267
 
203
- await preflightStaleBoard(
204
- path.join(path.dirname(path.resolve(process.cwd(), statusPath)), "server.json"),
268
+ const preflightServerJson = path.join(
269
+ path.dirname(path.resolve(process.cwd(), statusPath)),
270
+ "server.json",
205
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
+ }
206
285
 
207
286
  const { conductorPath: resolvedConductor, absStatus, server, serverJsonPath } =
208
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,8 +80,13 @@ 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).
@@ -87,9 +95,12 @@ steps:
87
95
  off to the next; read the previous step's finalBeat before you start one.
88
96
  For loop steps, update "completed" and the "iterations" object as EACH
89
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.
90
100
  Tag heartbeats with an "insight" object when you spot a way to improve the
91
101
  workflow. Before setting status to "done", review your insight-tagged
92
- 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.
93
104
  Set the top-level status to "done" when the last step completes.
94
105
  requires: [convert-to-conductor]
95
106
  gate: