conductor-board 2.0.0 → 2.2.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
@@ -44,15 +44,18 @@ 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" --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)
47
50
  loop <loop> <item> <sub> <state> Update a loop sub-step
48
- complete <id> [--attest-soft] Run hard gates independently, then advance
51
+ complete <step>[::iter::sub] [--attest-soft] Run hard gates, then advance
49
52
 
50
53
  Board options
51
54
  --path, -p <file> Path to status.json (default: .conductor/status.json)
52
55
  --conductor, -c <file> Path to the conductor (default: auto-discovered)
53
56
  --port <n> Port to serve on (default: 3042)
54
- --no-open Don't open the browser (CI / headless only;
55
- the board opens your browser by default)
57
+ --headless Don't open a browser (CI / cloud / no display).
58
+ Same as CONDUCTOR_HEADLESS=1. Default: opens.
56
59
 
57
60
  init options
58
61
  --name, -n <name> Workflow name (skips the prompts)
@@ -110,9 +113,18 @@ if (command === "clean") {
110
113
  }
111
114
 
112
115
  // status-writer commands (for agents — keep the board live as you work)
113
- if (["step", "gate", "heartbeat", "loop", "status-init"].includes(command)) {
116
+ if (["step", "gate", "heartbeat", "loop", "loop-scope", "status-init", "suggest", "knowledge"].includes(command)) {
114
117
  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];
118
+ const fn = {
119
+ step: w.runStep,
120
+ gate: w.runGate,
121
+ heartbeat: w.runHeartbeat,
122
+ loop: w.runLoop,
123
+ "loop-scope": w.runLoopScope,
124
+ "status-init": w.runStatusInit,
125
+ suggest: w.runSuggest,
126
+ knowledge: w.runKnowledge,
127
+ }[command];
116
128
  process.exit((await fn(rest)) ? 0 : 1);
117
129
  }
118
130
 
@@ -131,7 +143,10 @@ const statusPath = String(flag(["--path", "-p"], ".conductor/status.json"));
131
143
  const conductorArg = flag(["--conductor", "-c"], null);
132
144
  const conductorPath = conductorArg ? path.resolve(process.cwd(), String(conductorArg)) : null;
133
145
  const wantedPort = Number(flag(["--port"], 3042)) || 3042;
134
- const noOpen = flag(["--no-open"], false) === true;
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";
135
150
 
136
151
  function openBrowser(url) {
137
152
  const cmd =
@@ -200,6 +215,23 @@ function pidAlive(pid) {
200
215
  }
201
216
  }
202
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
+
203
235
  /**
204
236
  * Before binding a port, look for a board already serving this project.
205
237
  *
@@ -236,12 +268,22 @@ async function preflightStaleBoard(serverJsonPath) {
236
268
  return null;
237
269
  }
238
270
 
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
- };
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
+ }
245
287
 
246
288
  // Non-interactive (an agent, a script): never duplicate — reuse silently.
247
289
  if (!process.stdin.isTTY) return reuse;
@@ -300,7 +342,24 @@ if (resolvedConductor) {
300
342
  console.log(` ${dim("press ctrl+c to stop")}`);
301
343
  console.log("");
302
344
 
303
- if (!noOpen) openBrowser(url);
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
+ }
304
363
 
305
364
  function shutdown() {
306
365
  try {
@@ -314,3 +373,4 @@ function shutdown() {
314
373
 
315
374
  process.on("SIGINT", shutdown);
316
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
 
@@ -63,10 +74,29 @@ export async function runComplete(args) {
63
74
  console.error(red(`✗ could not parse conductor: ${e.message}`));
64
75
  return false;
65
76
  }
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;
77
+ // resolve the step either a top-level id, or a loop sub-step "loop::iter::sub"
78
+ const parts = stepId.split("::");
79
+ let step;
80
+ let loopPath = null;
81
+ if (parts.length === 3) {
82
+ const [loopId, iter, subId] = parts;
83
+ const loopStep = (doc.steps || []).find((s) => s && s.id === loopId && s.type === "loop");
84
+ if (!loopStep) {
85
+ console.error(red(`✗ conductor has no loop "${loopId}"`));
86
+ return false;
87
+ }
88
+ step = (loopStep.steps || []).find((s) => s && s.id === subId);
89
+ if (!step) {
90
+ console.error(red(`✗ loop "${loopId}" has no sub-step "${subId}"`));
91
+ return false;
92
+ }
93
+ loopPath = { loopId, iter, subId };
94
+ } else {
95
+ step = (doc.steps || []).find((s) => s && s.id === stepId);
96
+ if (!step) {
97
+ console.error(red(`✗ conductor has no step "${stepId}"`));
98
+ return false;
99
+ }
70
100
  }
71
101
 
72
102
  const soft = [];
@@ -117,7 +147,18 @@ export async function runComplete(args) {
117
147
  if (ok) {
118
148
  try {
119
149
  const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
120
- const st = (status.steps[stepId] = status.steps[stepId] || { attempt: 1 });
150
+ let st;
151
+ if (loopPath) {
152
+ const lp = (status.steps[loopPath.loopId] = status.steps[loopPath.loopId] || {
153
+ type: "loop",
154
+ iterations: {},
155
+ });
156
+ lp.iterations = lp.iterations || {};
157
+ const it = (lp.iterations[loopPath.iter] = lp.iterations[loopPath.iter] || {});
158
+ st = it[loopPath.subId] = it[loopPath.subId] || { attempt: 1 };
159
+ } else {
160
+ st = status.steps[stepId] = status.steps[stepId] || { attempt: 1 };
161
+ }
121
162
  st.status = "done";
122
163
  st.gate = "passed";
123
164
  st.gate_detail = detail;
@@ -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 --no-open here,
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
- Create .conductor/status.json with all steps pending and a timestamp run_id.
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,17 +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
- Tag heartbeats with an "insight" object when you spot a way to improve the
101
- workflow. Before setting status to "done", review your insight-tagged
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.
104
- Set the top-level status to "done" when the last step completes.
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"
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)"
111
122
  `;
112
123
 
113
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 [