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 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" Add a post-run suggestion (--type, --step, --rationale)
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
- (the board always opens your browser set
56
- CONDUCTOR_NO_OPEN=1 to suppress in CI/headless)
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. 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";
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 reuse = {
250
- reuse: true,
251
- pid,
252
- url: info.url || `http://localhost:${info.port ?? "?"}`,
253
- when: info.started_at ? ago(info.started_at) : "",
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 (!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
+ }
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
 
@@ -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,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
- 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"
111
- - name: "At least 3 suggestions written"
112
- check: "node -e \\"process.exit((JSON.parse(require('fs').readFileSync('.conductor/status.json','utf8')).suggestions||[]).length>=3?0:1)\\""
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 [