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 +64 -14
- package/cli/complete.js +12 -1
- package/cli/knowledge.js +109 -0
- package/cli/setup.js +19 -10
- package/cli/validate.js +2 -0
- package/cli/writer.js +231 -17
- package/dist/assets/index-DvTrz5lj.js +34 -0
- package/dist/assets/index-ilfk-igS.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/server.js +183 -54
- package/dist/assets/index-D0azgMCk.css +0 -1
- package/dist/assets/index-DV9PNUB3.js +0 -34
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"
|
|
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
|
-
|
|
56
|
-
|
|
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.
|
|
143
|
-
//
|
|
144
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 (!
|
|
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
|
|
package/cli/knowledge.js
ADDED
|
@@ -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 --
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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: "
|
|
112
|
-
check: "
|
|
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 --
|
|
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
|
-
|
|
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(`${
|
|
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 --
|
|
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" --
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
256
|
+
scope,
|
|
174
257
|
step: str(["--step"], undefined),
|
|
175
|
-
|
|
176
|
-
rationale: str(["--rationale"], undefined),
|
|
258
|
+
type: str(["--type"], undefined),
|
|
177
259
|
current: str(["--current"], undefined),
|
|
178
260
|
proposed: str(["--proposed"], undefined),
|
|
179
|
-
|
|
261
|
+
note: str(["--note"], undefined),
|
|
180
262
|
});
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
}
|