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 +88 -9
- package/cli/check.js +89 -0
- package/cli/clean.js +121 -0
- package/cli/complete.js +139 -0
- package/cli/discover.js +42 -0
- package/cli/ps.js +38 -0
- package/cli/setup.js +17 -6
- package/cli/stop.js +71 -0
- package/cli/validate.js +22 -3
- package/cli/writer.js +191 -0
- package/dist/assets/index-BIA1HyJ3.js +34 -0
- package/dist/assets/index-Di1Pl_pH.css +1 -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,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
|
|
158
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
path.
|
|
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
|
+
}
|
package/cli/complete.js
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|
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
|
|
31
|
-
automatically — do NOT pass --no-open here,
|
|
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
|
|
81
|
-
|
|
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:
|