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/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,6 +212,118 @@ export async function runLoop(args) {
155
212
  return ok(`${loopId}/${item}/${subId} → ${status}`);
156
213
  }
157
214
 
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.
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
+ }
235
+ const sp = statusPathOf(args);
236
+ const [title] = positionals(args);
237
+ if (!title) return fail('usage: conductor-board suggest "title" --scope this-conductor');
238
+ const str = (names, def) => {
239
+ const v = flag(args, names);
240
+ return typeof v === "string" ? v : def;
241
+ };
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, {
255
+ title,
256
+ scope,
257
+ step: str(["--step"], undefined),
258
+ type: str(["--type"], undefined),
259
+ current: str(["--current"], undefined),
260
+ proposed: str(["--proposed"], undefined),
261
+ note: str(["--note"], undefined),
262
+ });
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;
325
+ }
326
+
158
327
  // conductor-board status-init <conductor.yaml> [--run-id ID]
159
328
  export async function runStatusInit(args) {
160
329
  const [conductorPath] = positionals(args);
@@ -170,6 +339,73 @@ export async function runStatusInit(args) {
170
339
  (typeof flag(args, ["--run-id"]) === "string" && flag(args, ["--run-id"])) ||
171
340
  now().replace(/\.\d+Z$/, "").replace(/:/g, "-");
172
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
+
173
409
  for (const st of doc.steps || []) {
174
410
  if (!st || !st.id) continue;
175
411
  steps[st.id] =
@@ -187,5 +423,10 @@ export async function runStatusInit(args) {
187
423
  steps,
188
424
  };
189
425
  save(sp, status);
190
- 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
+ );
191
432
  }