conductor-board 2.1.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,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 [
package/cli/writer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import yaml from "js-yaml";
4
+ import { discoverConductor, loadConductor, mergeKnowledge, saveConductor, SCOPES } from "./knowledge.js";
4
5
 
5
6
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
6
7
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
@@ -96,7 +97,13 @@ export async function runGate(args) {
96
97
  return ok(`${id} gate → ${gate}`);
97
98
  }
98
99
 
99
- // conductor-board heartbeat <id> "note" [--iteration X --insight-type T --insight-seed S --final --to STEP]
100
+ // conductor-board heartbeat <id> "note" [--iteration X --sub Y --insight-type T
101
+ // --insight-seed S --insight-scope SC --final --to STEP]
102
+ //
103
+ // For a loop sub-step (--iteration AND --sub), the beat is written to the
104
+ // sub-step cell AND bubbled up to the loop parent's heartbeat array (tagged with
105
+ // iteration + sub) so the monitor and freeball banner — which read top-level
106
+ // arrays — see every level of activity without the agent beating twice.
100
107
  export async function runHeartbeat(args) {
101
108
  const sp = statusPathOf(args);
102
109
  const [id, note] = positionals(args);
@@ -104,15 +111,19 @@ export async function runHeartbeat(args) {
104
111
  const s = load(sp);
105
112
  if (!s) return fail("no status.json — run status-init first");
106
113
  const step = (s.steps[id] = s.steps[id] || { attempt: 1 });
114
+
107
115
  const entry = { at: now(), note };
108
116
  const it = flag(args, ["--iteration"]);
109
117
  if (typeof it === "string") entry.iteration = it;
118
+ const sub = flag(args, ["--sub"]);
119
+ if (typeof sub === "string") entry.sub = sub;
110
120
  const itype = flag(args, ["--insight-type"]);
111
121
  if (typeof itype === "string") {
112
122
  entry.insight = {
113
123
  type: itype,
114
124
  seed: typeof flag(args, ["--insight-seed"]) === "string" ? flag(args, ["--insight-seed"]) : note,
115
125
  step: id,
126
+ scope: typeof flag(args, ["--insight-scope"]) === "string" ? flag(args, ["--insight-scope"]) : "this-conductor",
116
127
  confidence: typeof flag(args, ["--insight-confidence"]) === "string" ? flag(args, ["--insight-confidence"]) : "medium",
117
128
  };
118
129
  }
@@ -121,9 +132,55 @@ export async function runHeartbeat(args) {
121
132
  const to = flag(args, ["--to"]);
122
133
  if (typeof to === "string") entry.handoff = { to };
123
134
  }
124
- (step.heartbeat = step.heartbeat || []).push(entry);
135
+
136
+ if (typeof it === "string" && typeof sub === "string") {
137
+ // Sub-step beat bubbled to the loop parent's array (tagged iteration + sub).
138
+ // The board reads top-level arrays for the monitor and the freeball banner,
139
+ // and the iteration cards filter this array by iteration/sub — so one write
140
+ // lights up every level. (We write to the parent only, never also the cell,
141
+ // to avoid double-counting since the readers aggregate the whole tree.)
142
+ step.type = "loop";
143
+ step.iterations = step.iterations || {};
144
+ const iter = (step.iterations[it] = step.iterations[it] || {});
145
+ iter[sub] = iter[sub] || { attempt: 1 };
146
+ (step.heartbeat = step.heartbeat || []).push(entry);
147
+ } else {
148
+ (step.heartbeat = step.heartbeat || []).push(entry);
149
+ }
150
+ save(sp, s);
151
+ return ok(`${id}${typeof sub === "string" ? `/${it}/${sub}` : ""} ♥ ${note.length > 50 ? note.slice(0, 50) + "…" : note}`);
152
+ }
153
+
154
+ // conductor-board loop-scope <loopId> <item...> [--note "..."]
155
+ //
156
+ // Frontload a loop's whole iteration list as pending the moment it's determined
157
+ // (§6.2): writes every item into the iterations map and sets total, so the board
158
+ // shows the full plan before any card moves. Also appends a "scope beat" naming
159
+ // the items, unless --note is given.
160
+ export async function runLoopScope(args) {
161
+ const sp = statusPathOf(args);
162
+ const [loopId, ...items] = positionals(args);
163
+ if (!loopId || items.length === 0)
164
+ return fail("usage: conductor-board loop-scope <loopId> <item1> <item2> … [--note \"...\"]");
165
+ const s = load(sp);
166
+ if (!s) return fail("no status.json — run status-init first");
167
+ const lp = (s.steps[loopId] = s.steps[loopId] || { type: "loop", iterations: {} });
168
+ lp.type = "loop";
169
+ lp.iterations = lp.iterations || {};
170
+ for (const item of items) {
171
+ lp.iterations[item] = lp.iterations[item] || {}; // sub-steps materialize as work begins
172
+ }
173
+ lp.total = items.length;
174
+ lp.completed = lp.completed || 0;
175
+ if (lp.status !== "running") lp.status = lp.status || "pending";
176
+ const noteFlag = flag(args, ["--note"]);
177
+ const note =
178
+ typeof noteFlag === "string"
179
+ ? noteFlag
180
+ : `${items.length} scoped: ${items.join(", ")}. All pending.`;
181
+ (lp.heartbeat = lp.heartbeat || []).push({ at: now(), note });
125
182
  save(sp, s);
126
- return ok(`${id} ${note.length > 50 ? note.slice(0, 50) + "…" : note}`);
183
+ return ok(`${loopId} scoped ${items.length} iterations frontloaded`);
127
184
  }
128
185
 
129
186
  // conductor-board loop <loopId> <item> <subId> <status>
@@ -155,31 +212,116 @@ export async function runLoop(args) {
155
212
  return ok(`${loopId}/${item}/${subId} → ${status}`);
156
213
  }
157
214
 
158
- // conductor-board suggest "title" --type T --step S --confidence C --rationale R [--current X --proposed Y]
215
+ // conductor-board suggest "title" --scope SC [--type T --step S --current X
216
+ // --proposed Y --note Z --conductor <file>]
217
+ //
218
+ // Writes the learning straight into the conductor file's knowledge: section —
219
+ // the conductor IS the knowledge base (§10.5). --scope is REQUIRED and routes
220
+ // the insight: this-conductor (auto-appliable in Phase 0) | upstream | template |
221
+ // tooling | corpus. A repeat sighting bumps `observed` and escalates the status
222
+ // (emerging → proven at 3×). Structural types (new_step/remove_step/reorder)
223
+ // need human approval, so they never auto-apply.
159
224
  export async function runSuggest(args) {
225
+ if (args.includes("--help") || args.includes("-h")) {
226
+ console.log(
227
+ 'usage: conductor-board suggest "title" --scope <scope> [--type <kind>] [--step <id>]\n' +
228
+ " [--current X] [--proposed Y] [--note Z] [--conductor <file>]\n" +
229
+ ` --scope (required): ${SCOPES.join(" | ")}\n` +
230
+ " Appends to the conductor's knowledge: section. this-conductor insights with\n" +
231
+ " current/proposed auto-apply once proven; structural types need approval.",
232
+ );
233
+ return true;
234
+ }
160
235
  const sp = statusPathOf(args);
161
236
  const [title] = positionals(args);
162
- if (!title) return fail('usage: conductor-board suggest "title" --type instruction --step <id>');
163
- const s = load(sp);
164
- if (!s) return fail("no status.json — run status-init first");
237
+ if (!title) return fail('usage: conductor-board suggest "title" --scope this-conductor');
165
238
  const str = (names, def) => {
166
239
  const v = flag(args, names);
167
240
  return typeof v === "string" ? v : def;
168
241
  };
169
- s.suggestions = Array.isArray(s.suggestions) ? s.suggestions : [];
170
- s.suggestions.push({
171
- id: `sg-${s.suggestions.length + 1}`,
242
+ const scope = str(["--scope"], undefined);
243
+ if (!scope) return fail("--scope is required (this-conductor | upstream | template | tooling | corpus)");
244
+ if (!SCOPES.includes(scope)) return fail(`--scope must be one of: ${SCOPES.join(", ")}`);
245
+
246
+ const conductorPath = discoverConductor(sp, str(["--conductor", "-c"], undefined));
247
+ if (!conductorPath) return fail("no conductor file found next to status.json or in cwd");
248
+ let doc;
249
+ try {
250
+ doc = loadConductor(conductorPath);
251
+ } catch (e) {
252
+ return fail(`could not parse conductor: ${e.message}`);
253
+ }
254
+ const merged = mergeKnowledge(doc, {
172
255
  title,
173
- type: str(["--type"], "instruction"),
256
+ scope,
174
257
  step: str(["--step"], undefined),
175
- confidence: str(["--confidence"], "medium"),
176
- rationale: str(["--rationale"], undefined),
258
+ type: str(["--type"], undefined),
177
259
  current: str(["--current"], undefined),
178
260
  proposed: str(["--proposed"], undefined),
179
- source_heartbeat: now(),
261
+ note: str(["--note"], undefined),
180
262
  });
181
- save(sp, s);
182
- return ok(`suggestion #${s.suggestions.length}: ${title.length > 50 ? title.slice(0, 50) + "…" : title}`);
263
+ const res = saveConductor(conductorPath, doc);
264
+ if (!res.ok) return fail(`knowledge not written ${res.error}`);
265
+ return ok(
266
+ `knowledge [${scope}] "${title.length > 46 ? title.slice(0, 46) + "…" : title}" — ${merged.status} (observed ${merged.observed}×)`,
267
+ );
268
+ }
269
+
270
+ // conductor-board knowledge [list] [--min N] [--scope SC] [--status ST] [--conductor file]
271
+ //
272
+ // With --min, exits 0 when the conductor holds at least N knowledge entries
273
+ // (use as the final-step "captured learnings" gate). With `list`, prints them.
274
+ export async function runKnowledge(args) {
275
+ const sp = statusPathOf(args);
276
+ const str = (names) => {
277
+ const v = flag(args, names);
278
+ return typeof v === "string" ? v : undefined;
279
+ };
280
+ const conductorPath = discoverConductor(sp, str(["--conductor", "-c"]));
281
+ if (!conductorPath) return fail("no conductor file found");
282
+ let doc;
283
+ try {
284
+ doc = loadConductor(conductorPath);
285
+ } catch (e) {
286
+ return fail(`could not parse conductor: ${e.message}`);
287
+ }
288
+ const all = (Array.isArray(doc.knowledge) ? doc.knowledge : []).filter(
289
+ (k) => k && typeof k === "object" && k.title,
290
+ );
291
+ const scope = str(["--scope"]);
292
+ const st = str(["--status"]);
293
+ const filtered = all.filter(
294
+ (k) =>
295
+ (!scope || (k.scope || "this-conductor") === scope) &&
296
+ (!st || (k.status || "emerging") === st),
297
+ );
298
+ // Quality gate (§3.5): enforce captured learnings by VALUE, not count.
299
+ // --min N at least N knowledge entries
300
+ // --min-scopes M entries span at least M distinct scopes (forces the
301
+ // cross-cutting reflection — the highest-leverage insights)
302
+ const min = str(["--min"]);
303
+ const minScopes = str(["--min-scopes"]);
304
+ if (min !== undefined || minScopes !== undefined) {
305
+ const n = min !== undefined ? Number(min) || 0 : 0;
306
+ const distinctScopes = new Set(filtered.map((k) => k.scope || "this-conductor")).size;
307
+ const m = minScopes !== undefined ? Number(minScopes) || 0 : 0;
308
+ const countOk = filtered.length >= n;
309
+ const scopesOk = distinctScopes >= m;
310
+ if (countOk && scopesOk)
311
+ return ok(`knowledge: ${filtered.length} entr${filtered.length === 1 ? "y" : "ies"}, ${distinctScopes} scope${distinctScopes === 1 ? "" : "s"} (ok)`);
312
+ const why = [];
313
+ if (!countOk) why.push(`need ≥ ${n} entries (have ${filtered.length})`);
314
+ if (!scopesOk) why.push(`need ≥ ${m} scopes (have ${distinctScopes})`);
315
+ return fail(
316
+ `knowledge gate: ${why.join(", ")} — capture what you learned, including cross-cutting:\n` +
317
+ ' conductor-board suggest "…" --scope upstream|template|tooling|corpus',
318
+ );
319
+ }
320
+ if (filtered.length === 0) console.log(dim(" (no knowledge yet)"));
321
+ for (const k of filtered) {
322
+ console.log(` ${k.status || "emerging"} · ${k.scope || "this-conductor"} · ${k.observed || 1}× — ${k.title}`);
323
+ }
324
+ return true;
183
325
  }
184
326
 
185
327
  // conductor-board status-init <conductor.yaml> [--run-id ID]
@@ -197,6 +339,73 @@ export async function runStatusInit(args) {
197
339
  (typeof flag(args, ["--run-id"]) === "string" && flag(args, ["--run-id"])) ||
198
340
  now().replace(/\.\d+Z$/, "").replace(/:/g, "-");
199
341
  const steps = {};
342
+
343
+ // Phase 0 (§10.2): auto-inject improvement cards from PROVEN this-conductor
344
+ // knowledge BEFORE the workflow steps. Entries with current/proposed apply
345
+ // automatically; structural ones (new_step/remove_step/reorder) are flagged
346
+ // for human approval. A _validate card closes the phase.
347
+ const STRUCTURAL = new Set(["new_step", "remove_step", "reorder"]);
348
+ const knowledge = (Array.isArray(doc.knowledge) ? doc.knowledge : []).filter(
349
+ (k) => k && typeof k === "object" && k.title,
350
+ );
351
+ const slug = (t) => String(t).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
352
+ const seen = new Set();
353
+
354
+ // _improve::read-knowledge leads the phase: read + categorize the knowledge.
355
+ if (knowledge.length > 0) {
356
+ const cat = (s) => knowledge.filter((k) => (k.status || "emerging") === s).length;
357
+ const cross = knowledge.filter((k) => (k.scope || "this-conductor") !== "this-conductor").length;
358
+ steps["_improve::read-knowledge"] = {
359
+ status: "pending",
360
+ gate: "pending",
361
+ attempt: 1,
362
+ improve: {
363
+ title: "Read knowledge",
364
+ kind: "read-knowledge",
365
+ note:
366
+ `${cat("proven")} proven · ${cat("emerging")} emerging · ${cat("applied")} applied · ` +
367
+ `${cross} cross-cutting`,
368
+ },
369
+ };
370
+ }
371
+
372
+ let improvements = 0;
373
+ for (const k of knowledge) {
374
+ if ((k.status || "emerging") !== "proven") continue;
375
+ if ((k.scope || "this-conductor") !== "this-conductor") continue;
376
+ const structural = STRUCTURAL.has(k.type);
377
+ const textChange = k.current && k.proposed;
378
+ if (!structural && !textChange) continue; // proven but nothing actionable
379
+ let id = `_improve::${slug(k.title)}`;
380
+ while (seen.has(id)) id += "-x";
381
+ seen.add(id);
382
+ steps[id] = {
383
+ status: "pending",
384
+ gate: "pending",
385
+ attempt: 1,
386
+ improve: {
387
+ step: k.step,
388
+ title: k.title,
389
+ current: k.current,
390
+ proposed: k.proposed,
391
+ note: k.note,
392
+ observed: k.observed || 1,
393
+ scope: k.scope || "this-conductor",
394
+ structural,
395
+ kind: k.type || "instruction",
396
+ },
397
+ };
398
+ improvements += 1;
399
+ }
400
+ if (improvements > 0) {
401
+ steps["_improve::validate"] = {
402
+ status: "pending",
403
+ gate: "pending",
404
+ attempt: 1,
405
+ improve: { title: "Validate conductor", kind: "validate" },
406
+ };
407
+ }
408
+
200
409
  for (const st of doc.steps || []) {
201
410
  if (!st || !st.id) continue;
202
411
  steps[st.id] =
@@ -214,5 +423,10 @@ export async function runStatusInit(args) {
214
423
  steps,
215
424
  };
216
425
  save(sp, status);
217
- return ok(`status.json initialized at ${path.relative(process.cwd(), sp)} (${Object.keys(steps).length} steps)`);
426
+ const workflowCount = (doc.steps || []).filter((s) => s && s.id).length;
427
+ return ok(
428
+ `status.json initialized (${workflowCount} steps` +
429
+ (improvements ? `, ${improvements} Phase 0 improvement${improvements === 1 ? "" : "s"}` : "") +
430
+ `)`,
431
+ );
218
432
  }