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 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
- --no-open Don't open the browser (CI / headless only;
40
- the board opens your browser by default)
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
- const noOpen = flag(["--no-open"], false) === true;
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 that's already running here. A live
158
- * one we offer to reclaim; a dead pid leaves a stale server.json we just clear.
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
- } else {
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
- await preflightStaleBoard(
204
- path.join(path.dirname(path.resolve(process.cwd(), statusPath)), "server.json"),
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
+ }
@@ -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
+ }
@@ -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
+ }