conductor-board 2.1.0 → 2.3.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 +64 -14
- package/cli/complete.js +12 -1
- package/cli/knowledge.js +109 -0
- package/cli/setup.js +19 -10
- package/cli/validate.js +2 -0
- package/cli/writer.js +257 -18
- package/dist/assets/index-B-Kwn4YB.js +34 -0
- package/dist/assets/index-a4RLv680.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/server.js +193 -55
- package/dist/assets/index-D0azgMCk.css +0 -1
- package/dist/assets/index-DV9PNUB3.js +0 -34
package/bin/cli.js
CHANGED
|
@@ -44,7 +44,9 @@ const HELP = `
|
|
|
44
44
|
gate <id> <state> checking | passed | failed
|
|
45
45
|
heartbeat <id> "note" Append a heartbeat (--iteration, --insight-type,
|
|
46
46
|
--insight-seed, --final, --to <step>)
|
|
47
|
-
suggest "title"
|
|
47
|
+
suggest "title" --scope SC Append a learning to the conductor's knowledge:
|
|
48
|
+
knowledge [--min N] List knowledge / gate on captured-learnings count
|
|
49
|
+
loop-scope <loop> <item...> Frontload every iteration as pending (scope beat)
|
|
48
50
|
loop <loop> <item> <sub> <state> Update a loop sub-step
|
|
49
51
|
complete <step>[::iter::sub] [--attest-soft] Run hard gates, then advance
|
|
50
52
|
|
|
@@ -52,8 +54,8 @@ const HELP = `
|
|
|
52
54
|
--path, -p <file> Path to status.json (default: .conductor/status.json)
|
|
53
55
|
--conductor, -c <file> Path to the conductor (default: auto-discovered)
|
|
54
56
|
--port <n> Port to serve on (default: 3042)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
--headless Don't open a browser (CI / cloud / no display).
|
|
58
|
+
Same as CONDUCTOR_HEADLESS=1. Default: opens.
|
|
57
59
|
|
|
58
60
|
init options
|
|
59
61
|
--name, -n <name> Workflow name (skips the prompts)
|
|
@@ -111,15 +113,17 @@ if (command === "clean") {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
// status-writer commands (for agents — keep the board live as you work)
|
|
114
|
-
if (["step", "gate", "heartbeat", "loop", "status-init", "suggest"].includes(command)) {
|
|
116
|
+
if (["step", "gate", "heartbeat", "loop", "loop-scope", "status-init", "suggest", "knowledge"].includes(command)) {
|
|
115
117
|
const w = await import("../cli/writer.js");
|
|
116
118
|
const fn = {
|
|
117
119
|
step: w.runStep,
|
|
118
120
|
gate: w.runGate,
|
|
119
121
|
heartbeat: w.runHeartbeat,
|
|
120
122
|
loop: w.runLoop,
|
|
123
|
+
"loop-scope": w.runLoopScope,
|
|
121
124
|
"status-init": w.runStatusInit,
|
|
122
125
|
suggest: w.runSuggest,
|
|
126
|
+
knowledge: w.runKnowledge,
|
|
123
127
|
}[command];
|
|
124
128
|
process.exit((await fn(rest)) ? 0 : 1);
|
|
125
129
|
}
|
|
@@ -139,9 +143,10 @@ const statusPath = String(flag(["--path", "-p"], ".conductor/status.json"));
|
|
|
139
143
|
const conductorArg = flag(["--conductor", "-c"], null);
|
|
140
144
|
const conductorPath = conductorArg ? path.resolve(process.cwd(), String(conductorArg)) : null;
|
|
141
145
|
const wantedPort = Number(flag(["--port"], 3042)) || 3042;
|
|
142
|
-
// The board always opens the browser — it's meant to be seen.
|
|
143
|
-
//
|
|
144
|
-
|
|
146
|
+
// The board always opens the browser — it's meant to be seen. --headless (or
|
|
147
|
+
// CONDUCTOR_HEADLESS=1) suppresses it for CI / cloud / no-display environments.
|
|
148
|
+
// The name signals "I have no display", not "I don't want to look".
|
|
149
|
+
const headless = argv.includes("--headless") || process.env.CONDUCTOR_HEADLESS === "1";
|
|
145
150
|
|
|
146
151
|
function openBrowser(url) {
|
|
147
152
|
const cmd =
|
|
@@ -210,6 +215,23 @@ function pidAlive(pid) {
|
|
|
210
215
|
}
|
|
211
216
|
}
|
|
212
217
|
|
|
218
|
+
// Probe a recorded board's /health endpoint — distinguishes a live, serving
|
|
219
|
+
// board (reuse it) from a wedged pid that holds the port but isn't responding.
|
|
220
|
+
async function boardHealthy(url) {
|
|
221
|
+
if (!url) return false;
|
|
222
|
+
try {
|
|
223
|
+
const ctrl = new AbortController();
|
|
224
|
+
const t = setTimeout(() => ctrl.abort(), 1200);
|
|
225
|
+
const r = await fetch(`${url}/health`, { signal: ctrl.signal });
|
|
226
|
+
clearTimeout(t);
|
|
227
|
+
if (!r.ok) return false;
|
|
228
|
+
const body = await r.json().catch(() => null);
|
|
229
|
+
return !!body && body.status === "ok";
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
213
235
|
/**
|
|
214
236
|
* Before binding a port, look for a board already serving this project.
|
|
215
237
|
*
|
|
@@ -246,12 +268,22 @@ async function preflightStaleBoard(serverJsonPath) {
|
|
|
246
268
|
return null;
|
|
247
269
|
}
|
|
248
270
|
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
271
|
+
const url = info.url || `http://localhost:${info.port ?? "?"}`;
|
|
272
|
+
const reuse = { reuse: true, pid, url, when: info.started_at ? ago(info.started_at) : "" };
|
|
273
|
+
|
|
274
|
+
// Alive but unhealthy (wedged — holds the port but /health doesn't answer):
|
|
275
|
+
// kill it and start fresh, so a hung board can't block a clean restart (§4.6).
|
|
276
|
+
if (!(await boardHealthy(url))) {
|
|
277
|
+
try {
|
|
278
|
+
process.kill(pid, "SIGTERM");
|
|
279
|
+
} catch {
|
|
280
|
+
/* may have just exited */
|
|
281
|
+
}
|
|
282
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
283
|
+
clear();
|
|
284
|
+
console.log(amber(`\n ⚠ board pid ${pid} was unresponsive — stopped it and starting fresh.`));
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
255
287
|
|
|
256
288
|
// Non-interactive (an agent, a script): never duplicate — reuse silently.
|
|
257
289
|
if (!process.stdin.isTTY) return reuse;
|
|
@@ -310,7 +342,24 @@ if (resolvedConductor) {
|
|
|
310
342
|
console.log(` ${dim("press ctrl+c to stop")}`);
|
|
311
343
|
console.log("");
|
|
312
344
|
|
|
313
|
-
if (!
|
|
345
|
+
if (!headless) openBrowser(url);
|
|
346
|
+
|
|
347
|
+
// Subdirectory convention (§4.7): warn if a flat .conductor/status.json is in
|
|
348
|
+
// use. One workflow per .conductor/<name>/ keeps history and insights separate.
|
|
349
|
+
try {
|
|
350
|
+
const flat = path.resolve(process.cwd(), ".conductor", "status.json");
|
|
351
|
+
if (path.resolve(absStatus) === flat && fs.existsSync(flat)) {
|
|
352
|
+
console.log(
|
|
353
|
+
amber(" ⚠ flat .conductor/status.json detected") +
|
|
354
|
+
dim(" — the convention is .conductor/<workflow-name>/. It still works,") +
|
|
355
|
+
"\n" +
|
|
356
|
+
dim(" but subdirectories keep each workflow's history + insights separate."),
|
|
357
|
+
);
|
|
358
|
+
console.log("");
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
/* advisory only */
|
|
362
|
+
}
|
|
314
363
|
|
|
315
364
|
function shutdown() {
|
|
316
365
|
try {
|
|
@@ -324,3 +373,4 @@ function shutdown() {
|
|
|
324
373
|
|
|
325
374
|
process.on("SIGINT", shutdown);
|
|
326
375
|
process.on("SIGTERM", shutdown);
|
|
376
|
+
process.on("SIGQUIT", shutdown);
|
package/cli/complete.js
CHANGED
|
@@ -41,13 +41,24 @@ function discoverConductor(statusPath, explicit) {
|
|
|
41
41
|
* with gate_detail tagging each criterion 🔒 verified (CLI ran it) or ✋ attested.
|
|
42
42
|
*/
|
|
43
43
|
export async function runComplete(args) {
|
|
44
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
45
|
+
console.log(
|
|
46
|
+
"usage: conductor-board complete <step-id> [--attest-soft]\n" +
|
|
47
|
+
" conductor-board complete <loop-id>::<iteration>::<sub-step> [--attest-soft]\n\n" +
|
|
48
|
+
" Runs the step's HARD gates independently (you can't fake them — the board\n" +
|
|
49
|
+
" shows 🔒 verified vs ✋ attested) and only advances when they pass.\n" +
|
|
50
|
+
" The :: form resolves a loop sub-step inside the conductor's loop definition.\n" +
|
|
51
|
+
" --attest-soft: mark the soft gates as attested once you've verified them.",
|
|
52
|
+
);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
44
55
|
const p = flag(args, ["--path", "-p"]);
|
|
45
56
|
const statusPath = path.resolve(process.cwd(), typeof p === "string" ? p : ".conductor/status.json");
|
|
46
57
|
const stepId = args.find((a) => !a.startsWith("-"));
|
|
47
58
|
const attestSoft = args.includes("--attest-soft");
|
|
48
59
|
|
|
49
60
|
if (!stepId) {
|
|
50
|
-
console.error(red("usage: conductor-board complete <step-id> [--attest-soft]"));
|
|
61
|
+
console.error(red("usage: conductor-board complete <step-id>[::iter::sub] [--attest-soft]"));
|
|
51
62
|
return false;
|
|
52
63
|
}
|
|
53
64
|
|
package/cli/knowledge.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { validateConductor } from "./validate.js";
|
|
5
|
+
|
|
6
|
+
export const SCOPES = ["this-conductor", "upstream", "template", "tooling", "corpus"];
|
|
7
|
+
|
|
8
|
+
/** Find the conductor file paired with a status.json. */
|
|
9
|
+
export function discoverConductor(statusPath, explicit) {
|
|
10
|
+
if (explicit) {
|
|
11
|
+
const p = path.resolve(process.cwd(), explicit);
|
|
12
|
+
return fs.existsSync(p) ? p : null;
|
|
13
|
+
}
|
|
14
|
+
const dir = path.dirname(statusPath);
|
|
15
|
+
for (const c of ["conductor.yaml", "conductor.yml"]) {
|
|
16
|
+
const p = path.join(dir, c);
|
|
17
|
+
if (fs.existsSync(p)) return p;
|
|
18
|
+
}
|
|
19
|
+
if (fs.existsSync(dir)) {
|
|
20
|
+
const y = fs.readdirSync(dir).find((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
|
|
21
|
+
if (y) return path.join(dir, y);
|
|
22
|
+
}
|
|
23
|
+
for (const c of ["conductor.yaml", "conductor.yml"]) {
|
|
24
|
+
const p = path.resolve(process.cwd(), c);
|
|
25
|
+
if (fs.existsSync(p)) return p;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const norm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The status a knowledge entry should hold given its evidence (§10.3):
|
|
34
|
+
* 1 observation → emerging · 3+ → proven (this-conductor) · applied is sticky.
|
|
35
|
+
* Cross-cutting scopes can't be auto-applied, so they sit at `open`.
|
|
36
|
+
*/
|
|
37
|
+
export function statusFor(entry) {
|
|
38
|
+
if (entry.status === "applied") return "applied";
|
|
39
|
+
if (entry.scope && entry.scope !== "this-conductor") return "open";
|
|
40
|
+
return (entry.observed || 1) >= 3 ? "proven" : "emerging";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Merge one learning into a conductor doc's knowledge array (in place). */
|
|
44
|
+
export function mergeKnowledge(doc, entry) {
|
|
45
|
+
doc.knowledge = Array.isArray(doc.knowledge) ? doc.knowledge : [];
|
|
46
|
+
const existing = doc.knowledge.find(
|
|
47
|
+
(k) => k && typeof k === "object" && norm(k.title) === norm(entry.title),
|
|
48
|
+
);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.observed = (existing.observed || 1) + 1;
|
|
51
|
+
if (entry.scope) existing.scope = entry.scope;
|
|
52
|
+
if (entry.step) existing.step = entry.step;
|
|
53
|
+
if (entry.type) existing.type = entry.type;
|
|
54
|
+
if (entry.current) existing.current = entry.current;
|
|
55
|
+
if (entry.proposed) existing.proposed = entry.proposed;
|
|
56
|
+
if (entry.note) existing.note = entry.note;
|
|
57
|
+
existing.status = statusFor(existing);
|
|
58
|
+
return existing;
|
|
59
|
+
}
|
|
60
|
+
const fresh = {
|
|
61
|
+
title: entry.title,
|
|
62
|
+
scope: entry.scope || "this-conductor",
|
|
63
|
+
observed: entry.observed || 1,
|
|
64
|
+
...(entry.step ? { step: entry.step } : {}),
|
|
65
|
+
...(entry.type ? { type: entry.type } : {}),
|
|
66
|
+
...(entry.current ? { current: entry.current } : {}),
|
|
67
|
+
...(entry.proposed ? { proposed: entry.proposed } : {}),
|
|
68
|
+
...(entry.note ? { note: entry.note } : {}),
|
|
69
|
+
};
|
|
70
|
+
fresh.status = statusFor(fresh);
|
|
71
|
+
doc.knowledge.push(fresh);
|
|
72
|
+
return fresh;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read + parse a conductor file. Throws on parse error. */
|
|
76
|
+
export function loadConductor(conductorPath) {
|
|
77
|
+
return yaml.load(fs.readFileSync(conductorPath, "utf8")) || {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write a conductor doc back, with a single rolling backup and a re-validation.
|
|
82
|
+
* Returns { ok, error } — never throws.
|
|
83
|
+
*/
|
|
84
|
+
export function saveConductor(conductorPath, doc) {
|
|
85
|
+
const errors = validateConductor(doc);
|
|
86
|
+
if (errors.length) return { ok: false, error: `would be invalid: ${errors[0]}` };
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(`${conductorPath}.bak`, fs.readFileSync(conductorPath, "utf8"));
|
|
89
|
+
} catch {
|
|
90
|
+
/* backup is best-effort */
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
fs.writeFileSync(conductorPath, yaml.dump(doc, { lineWidth: 100 }));
|
|
94
|
+
return { ok: true };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { ok: false, error: e.message };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Count knowledge entries (optionally filtered by status/scope). */
|
|
101
|
+
export function knowledgeCount(doc, { status, scope } = {}) {
|
|
102
|
+
const k = Array.isArray(doc.knowledge) ? doc.knowledge : [];
|
|
103
|
+
return k.filter(
|
|
104
|
+
(e) =>
|
|
105
|
+
e &&
|
|
106
|
+
(!status || (e.status || "emerging") === status) &&
|
|
107
|
+
(!scope || (e.scope || "this-conductor") === scope),
|
|
108
|
+
).length;
|
|
109
|
+
}
|
package/cli/setup.js
CHANGED
|
@@ -28,8 +28,8 @@ steps:
|
|
|
28
28
|
- id: start-board
|
|
29
29
|
instruction: |
|
|
30
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 --
|
|
32
|
-
that defeats the live view.
|
|
31
|
+
whole run. It opens the browser automatically — do NOT pass --headless here,
|
|
32
|
+
that defeats the live view (--headless is only for CI / no-display runs).
|
|
33
33
|
npx conductor-board >/tmp/conductor-board.log 2>&1 &
|
|
34
34
|
Wait ~3 seconds for it to initialize. It auto-detects a free port if 3042
|
|
35
35
|
is taken and records the chosen port in .conductor/server.json. Do NOT run
|
|
@@ -77,7 +77,12 @@ steps:
|
|
|
77
77
|
- id: execute-workflow
|
|
78
78
|
instruction: |
|
|
79
79
|
Execute the generated conductor workflow.
|
|
80
|
-
|
|
80
|
+
Initialize the board with: conductor-board status-init .conductor/conductor.yaml
|
|
81
|
+
This also auto-injects a Phase 0 "improvement" pass — if the conductor's
|
|
82
|
+
knowledge section holds any PROVEN this-conductor insights with current/
|
|
83
|
+
proposed text, apply each (rewrite the named step), then validate, before
|
|
84
|
+
step 1. Structural changes (add/remove/reorder a step) wait for human
|
|
85
|
+
Approve on the board. If nothing is proven, Phase 0 is empty — start the work.
|
|
81
86
|
Set the top-level "goal" from the conductor's description, and refresh
|
|
82
87
|
"current_step_goal" each time current_step changes.
|
|
83
88
|
Walk each step in order, updating status.json after EVERY transition
|
|
@@ -97,19 +102,23 @@ steps:
|
|
|
97
102
|
iteration finishes — don't wait until the loop ends.
|
|
98
103
|
At the START of the run, read .conductor/insights.md (if it exists) to carry
|
|
99
104
|
forward what past runs learned — don't repeat insights already recorded there.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
At run end, before setting status "done", write what you learned into the
|
|
106
|
+
conductor's knowledge section — the conductor IS the knowledge base. Use:
|
|
107
|
+
conductor-board suggest "title" --scope <scope> [--step S --current X --proposed Y]
|
|
108
|
+
--scope is REQUIRED (this-conductor | upstream | template | tooling | corpus).
|
|
109
|
+
A repeat sighting escalates emerging -> proven (3x); proven this-conductor
|
|
110
|
+
insights auto-apply in the next run's Phase 0. Browse them on the board's
|
|
111
|
+
✨ Insights page. Set the top-level status to "done" when the last step
|
|
112
|
+
completes.
|
|
105
113
|
requires: [convert-to-conductor]
|
|
106
114
|
gate:
|
|
107
115
|
- name: "Status file exists"
|
|
108
116
|
check: "test -f .conductor/status.json"
|
|
109
117
|
- name: "Workflow completed successfully"
|
|
110
118
|
check: "node -p \\"JSON.parse(require('fs').readFileSync('.conductor/status.json','utf8')).status\\" | grep done"
|
|
111
|
-
- name: "
|
|
112
|
-
check: "
|
|
119
|
+
- name: "Captured cross-cutting learnings (≥1 insight, ≥2 scopes)"
|
|
120
|
+
check: "npx conductor-board knowledge --min 1 --min-scopes 2"
|
|
121
|
+
- "Answered: what did I learn that does NOT fit a step of this workflow? (upstream, template, tooling, or corpus insights logged with scope tags)"
|
|
113
122
|
`;
|
|
114
123
|
|
|
115
124
|
export async function runSetup(args) {
|
package/cli/validate.js
CHANGED
|
@@ -143,6 +143,8 @@ export function validateConductor(doc) {
|
|
|
143
143
|
if (!Array.isArray(s.steps) || s.steps.length === 0)
|
|
144
144
|
errors.push(`Loop "${s.id}" has no sub-steps`);
|
|
145
145
|
else validateSubSteps(s, errors);
|
|
146
|
+
if (s.parallel !== undefined && s.parallel !== true && s.parallel !== false && s.parallel !== "auto")
|
|
147
|
+
errors.push(`Loop "${s.id}" has invalid "parallel" (use true, false, or auto)`);
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
for (const [field, val] of [
|