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/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]
@@ -196,7 +338,97 @@ export async function runStatusInit(args) {
196
338
  const runId =
197
339
  (typeof flag(args, ["--run-id"]) === "string" && flag(args, ["--run-id"])) ||
198
340
  now().replace(/\.\d+Z$/, "").replace(/:/g, "-");
341
+ const wfName = doc.name || "workflow";
342
+
343
+ // §6.2 — every run gets a human name: {workflow}-run-{N}-{timestamp}. N is the
344
+ // count of archived runs + 1; the timestamp is the run id trimmed to minutes.
345
+ const nameSlug = String(wfName).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
346
+ const historyDir = path.join(path.dirname(sp), "history");
347
+ let priorRuns = 0;
348
+ try {
349
+ priorRuns = fs.readdirSync(historyDir).filter((f) => f.endsWith(".json")).length;
350
+ } catch {
351
+ /* no history yet */
352
+ }
353
+ const tsShort = runId.replace(/-\d{2}$/, ""); // 2026-06-04T12-30-00 → 2026-06-04T12-30
354
+ const runName =
355
+ (typeof flag(args, ["--run-name"]) === "string" && flag(args, ["--run-name"])) ||
356
+ `${nameSlug}-run-${priorRuns + 1}-${tsShort}`;
357
+
358
+ // §6.1 — auto_improve (default on). When off, the Phase 0 self-improvement
359
+ // pass is fully disabled: no improvement cards injected at all.
360
+ const autoImprove = doc.auto_improve !== false;
361
+
199
362
  const steps = {};
363
+ let improvements = 0;
364
+
365
+ if (autoImprove) {
366
+ // Phase 0 (§10.2): auto-inject improvement cards from PROVEN this-conductor
367
+ // knowledge BEFORE the workflow steps. Entries with current/proposed apply
368
+ // automatically; structural ones (new_step/remove_step/reorder) are flagged
369
+ // for human approval. A _validate card closes the phase.
370
+ const STRUCTURAL = new Set(["new_step", "remove_step", "reorder"]);
371
+ const knowledge = (Array.isArray(doc.knowledge) ? doc.knowledge : []).filter(
372
+ (k) => k && typeof k === "object" && k.title,
373
+ );
374
+ const slug = (t) => String(t).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
375
+ const seen = new Set();
376
+
377
+ // _improve::read-knowledge leads the phase: read + categorize the knowledge.
378
+ if (knowledge.length > 0) {
379
+ const cat = (s) => knowledge.filter((k) => (k.status || "emerging") === s).length;
380
+ const cross = knowledge.filter((k) => (k.scope || "this-conductor") !== "this-conductor").length;
381
+ steps["_improve::read-knowledge"] = {
382
+ status: "pending",
383
+ gate: "pending",
384
+ attempt: 1,
385
+ improve: {
386
+ title: "Read knowledge",
387
+ kind: "read-knowledge",
388
+ note:
389
+ `${cat("proven")} proven · ${cat("emerging")} emerging · ${cat("applied")} applied · ` +
390
+ `${cross} cross-cutting`,
391
+ },
392
+ };
393
+ }
394
+
395
+ for (const k of knowledge) {
396
+ if ((k.status || "emerging") !== "proven") continue;
397
+ if ((k.scope || "this-conductor") !== "this-conductor") continue;
398
+ const structural = STRUCTURAL.has(k.type);
399
+ const textChange = k.current && k.proposed;
400
+ if (!structural && !textChange) continue; // proven but nothing actionable
401
+ let id = `_improve::${slug(k.title)}`;
402
+ while (seen.has(id)) id += "-x";
403
+ seen.add(id);
404
+ steps[id] = {
405
+ status: "pending",
406
+ gate: "pending",
407
+ attempt: 1,
408
+ improve: {
409
+ step: k.step,
410
+ title: k.title,
411
+ current: k.current,
412
+ proposed: k.proposed,
413
+ note: k.note,
414
+ observed: k.observed || 1,
415
+ scope: k.scope || "this-conductor",
416
+ structural,
417
+ kind: k.type || "instruction",
418
+ },
419
+ };
420
+ improvements += 1;
421
+ }
422
+ if (improvements > 0) {
423
+ steps["_improve::validate"] = {
424
+ status: "pending",
425
+ gate: "pending",
426
+ attempt: 1,
427
+ improve: { title: "Validate conductor", kind: "validate" },
428
+ };
429
+ }
430
+ }
431
+
200
432
  for (const st of doc.steps || []) {
201
433
  if (!st || !st.id) continue;
202
434
  steps[st.id] =
@@ -205,8 +437,10 @@ export async function runStatusInit(args) {
205
437
  : { status: "pending", gate: "pending", attempt: 1 };
206
438
  }
207
439
  const status = {
208
- workflow: doc.name || "workflow",
440
+ workflow: wfName,
209
441
  run_id: runId,
442
+ run_name: runName,
443
+ auto_improve: autoImprove,
210
444
  status: "running",
211
445
  goal: (doc.description || "").trim().replace(/\s+/g, " "),
212
446
  current_step: null,
@@ -214,5 +448,10 @@ export async function runStatusInit(args) {
214
448
  steps,
215
449
  };
216
450
  save(sp, status);
217
- return ok(`status.json initialized at ${path.relative(process.cwd(), sp)} (${Object.keys(steps).length} steps)`);
451
+ const workflowCount = (doc.steps || []).filter((s) => s && s.id).length;
452
+ return ok(
453
+ `status.json initialized (${workflowCount} steps` +
454
+ (improvements ? `, ${improvements} Phase 0 improvement${improvements === 1 ? "" : "s"}` : "") +
455
+ `)`,
456
+ );
218
457
  }