bullswarm 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -14,6 +14,7 @@ import { getAllMeterReadings } from './meters/registry.js';
14
14
  import { judgeContent } from './lib/verify.js';
15
15
  import { getVersion } from './lib/version.js';
16
16
  import { release } from './lib/release.js';
17
+ import { cmdWorkflow } from './workflow/cli.js';
17
18
 
18
19
  export const BULLSWARM_DIR = join(homedir(), '.bullswarm');
19
20
 
@@ -99,7 +100,7 @@ async function cmdRun(opts) {
99
100
 
100
101
  const route = pickPool(lane, eligiblePools, {
101
102
  callerEligible: opts['no-caller'] !== true,
102
- callerName: state.config.callerName ?? 'claude',
103
+ callerName: state.config.callerName ?? 'claude-code',
103
104
  now,
104
105
  });
105
106
  if (gated.length && route.pick) {
@@ -360,6 +361,8 @@ export async function main(argv) {
360
361
  return cmdPools(opts);
361
362
  case 'doctor':
362
363
  return cmdDoctor(opts);
364
+ case 'workflow':
365
+ return cmdWorkflow(rest);
363
366
  case 'version':
364
367
  console.log(getVersion());
365
368
  return 0;
@@ -367,7 +370,7 @@ export async function main(argv) {
367
370
  return cmdRelease(opts);
368
371
  default:
369
372
  console.error(
370
- `unknown verb "${verb}". try: setup | run | health | pools | doctor | version | release`,
373
+ `unknown verb "${verb}". try: setup | run | health | pools | doctor | workflow | version | release`,
371
374
  );
372
375
  return 2;
373
376
  }
package/src/lib/route.js CHANGED
@@ -84,7 +84,7 @@ export function isExhausted(pool) {
84
84
  export function pickPool(lane, pools, opts = {}) {
85
85
  const {
86
86
  callerEligible = true,
87
- callerName = 'claude',
87
+ callerName = 'claude-code',
88
88
  now = Date.now(),
89
89
  } = opts;
90
90
 
@@ -158,7 +158,18 @@ export function pickPool(lane, pools, opts = {}) {
158
158
 
159
159
  // R5: the caller wins its lane only when no eligible delegate remains —
160
160
  // or when the caller's own pool entry genuinely wins on merit. Dispatching
161
- // the caller to itself as a subprocess is always wrong.
161
+ // the caller to itself as a subprocess is always wrong — BUT only when a
162
+ // caller session actually exists. In workflow/batch contexts every pool is
163
+ // just a worker; opts.callerSession (default: callerEligible) controls it.
164
+ const hasCallerSession = opts.callerSession ?? callerEligible;
165
+ if (!hasCallerSession) {
166
+ return {
167
+ pick: { pool: winnerEntry.pool.name, connector: winnerEntry.pool },
168
+ keepOnClaude: false,
169
+ why: `most-behind capable pool (surplus ${Math.round(winnerEntry.pace * 10) / 10})`,
170
+ candidates,
171
+ };
172
+ }
162
173
  const isCaller =
163
174
  winnerEntry.pool.isCaller === true ||
164
175
  winnerEntry.pool.connector?.flags?.isCaller === true ||
package/src/lib/state.js CHANGED
@@ -20,7 +20,7 @@ export const DEFAULT_STATE = {
20
20
  decisionLog: [], // {ts, lane, picked, keepOnClaude, ok, why, wallSec}
21
21
  config: {
22
22
  depthLimit: 2,
23
- callerName: 'claude',
23
+ callerName: 'claude-code',
24
24
  },
25
25
  };
26
26
 
@@ -0,0 +1,172 @@
1
+ // bullswarm workflow CLI — run | validate | list.
2
+
3
+ import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { loadWorkflow, runWorkflow } from './runner.js';
7
+ import { validateWorkflow, WorkflowValidationError } from './validate.js';
8
+ import { buildPoolsLive } from '../lib/config.js';
9
+ import { getAllMeterReadings } from '../meters/registry.js';
10
+ import { WorkflowTui } from './tui.js';
11
+
12
+ export const BULLSWARM_DIR = join(homedir(), '.bullswarm');
13
+
14
+ function workflowDirs() {
15
+ return [
16
+ join(process.cwd(), 'workflows'),
17
+ join(BULLSWARM_DIR, 'workflows'),
18
+ ];
19
+ }
20
+
21
+ function discover() {
22
+ const found = [];
23
+ for (const dir of workflowDirs()) {
24
+ if (!existsSync(dir)) continue;
25
+ for (const f of readdirSync(dir).sort()) {
26
+ const p = join(dir, f);
27
+ if (!statSync(p).isFile() || !f.endsWith('.json')) continue;
28
+ try {
29
+ const doc = JSON.parse(readFileSync(p, 'utf8'));
30
+ found.push({ name: doc.name ?? f.replace(/\.json$/, ''), path: p, valid: null });
31
+ } catch (err) {
32
+ found.push({ name: f.replace(/\.json$/, ''), path: p, valid: `parse error: ${err.message}` });
33
+ }
34
+ }
35
+ }
36
+ return found;
37
+ }
38
+
39
+ export async function cmdWorkflow(args) {
40
+ const [sub, ...rest] = args;
41
+ const opts = parseFlags(rest);
42
+
43
+ switch (sub) {
44
+ case 'run':
45
+ return wfRun(opts);
46
+ case 'validate':
47
+ return wfValidate(opts);
48
+ case 'list':
49
+ return wfList(opts);
50
+ default:
51
+ console.error('usage: bullswarm workflow <run|validate|list> [file|name] [--input k=v] [--resume id] [--json] [--quiet]');
52
+ return 2;
53
+ }
54
+ }
55
+
56
+ function parseFlags(argv) {
57
+ const out = { inputs: {}, rest: [] };
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const a = argv[i];
60
+ if (a === '--json') out.json = true;
61
+ else if (a === '--quiet') out.quiet = true;
62
+ else if (a === '--resume') out.resume = argv[++i];
63
+ else if (a === '--input') {
64
+ const kv = argv[++i] ?? '';
65
+ const eq = kv.indexOf('=');
66
+ if (eq > 0) out.inputs[kv.slice(0, eq)] = kv.slice(eq + 1);
67
+ } else if (a.startsWith('--')) {
68
+ out[a.slice(2)] = true;
69
+ } else out.rest.push(a);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ async function wfValidate(opts) {
75
+ const target = opts.rest[0];
76
+ if (!target) {
77
+ console.error('usage: bullswarm workflow validate <file-or-name>');
78
+ return 2;
79
+ }
80
+ let doc, path;
81
+ try {
82
+ ({ doc, path } = loadWorkflow(target, workflowDirs()));
83
+ } catch (err) {
84
+ console.error(`✗ ${err.message}`);
85
+ return 1;
86
+ }
87
+ const poolsInfo = await livePoolNames();
88
+ try {
89
+ const r = validateWorkflow(doc, { poolNames: poolsInfo.names });
90
+ console.log(`✓ ${path} is valid (${doc.phases?.length ?? 0} phases)`);
91
+ for (const w of r.warnings) console.log(` ⚠ ${w}`);
92
+ return 0;
93
+ } catch (err) {
94
+ if (err instanceof WorkflowValidationError) {
95
+ console.error(`✗ ${path}:`);
96
+ for (const issue of err.issues) console.error(` - ${issue}`);
97
+ return 1;
98
+ }
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ async function livePoolNames() {
104
+ try {
105
+ const { pools } = await buildPoolsLive(BULLSWARM_DIR, Date.now(), {
106
+ getReadings: getAllMeterReadings,
107
+ });
108
+ return { names: pools.map((p) => p.name), pools };
109
+ } catch {
110
+ return { names: [], pools: [] };
111
+ }
112
+ }
113
+
114
+ async function wfRun(opts) {
115
+ const target = opts.rest[0];
116
+ if (!target) {
117
+ console.error('usage: bullswarm workflow run <file-or-name> [--input k=v] [--resume runId]');
118
+ return 2;
119
+ }
120
+
121
+ let doc, path;
122
+ try {
123
+ ({ doc, path } = loadWorkflow(target, workflowDirs()));
124
+ } catch (err) {
125
+ console.error(`✗ ${err.message}`);
126
+ return 1;
127
+ }
128
+
129
+ const { names, pools } = await livePoolNames();
130
+ try {
131
+ validateWorkflow(doc, { poolNames: names });
132
+ } catch (err) {
133
+ if (err instanceof WorkflowValidationError) {
134
+ console.error(`✗ workflow invalid (nothing ran):`);
135
+ for (const issue of err.issues) console.error(` - ${issue}`);
136
+ return 1;
137
+ }
138
+ throw err;
139
+ }
140
+
141
+ const tui = new WorkflowTui({ quiet: opts.quiet, json: opts.json });
142
+ const result = await runWorkflow({
143
+ bullswarmDir: BULLSWARM_DIR,
144
+ doc,
145
+ pools,
146
+ inputs: opts.inputs,
147
+ resumeRunId: opts.resume,
148
+ onEvent: (ev) => tui.handle(ev),
149
+ });
150
+
151
+ if (opts.json) {
152
+ console.log(JSON.stringify(result.report, null, 2));
153
+ }
154
+ return result.report.status === 'completed' ? 0 : 1;
155
+ }
156
+
157
+ function wfList(opts) {
158
+ const found = discover();
159
+ if (opts.json) {
160
+ console.log(JSON.stringify({ workflows: found }, null, 2));
161
+ return 0;
162
+ }
163
+ if (found.length === 0) {
164
+ console.log(`no workflows found in: ${workflowDirs().join(', ')}`);
165
+ return 0;
166
+ }
167
+ for (const w of found) {
168
+ const mark = w.valid ? `✗ ${w.valid}` : '✓';
169
+ console.log(`${mark} ${w.name.padEnd(24)} ${w.path}`);
170
+ }
171
+ return 0;
172
+ }
@@ -0,0 +1,195 @@
1
+ // bullswarm workflow runner — load, validate, execute phases, report.
2
+ //
3
+ // Phase semantics:
4
+ // step onError: continue | fail (abort whole run) | skip-phase
5
+ // settings.stopOnPhaseFailure: abort after a phase with any failure
6
+ // Resume: steps whose recorded verdict is ok:true are skipped (R2).
7
+
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { validateWorkflow } from './validate.js';
12
+ import { WorkflowRuntime } from './runtime.js';
13
+
14
+ export function loadWorkflow(pathOrName, searchDirs) {
15
+ let path = pathOrName;
16
+ if (!path.endsWith('.json')) {
17
+ const candidates = searchDirs.flatMap((d) => [
18
+ join(d, `${pathOrName}.json`),
19
+ join(d, pathOrName, 'workflow.json'),
20
+ ]);
21
+ path = candidates.find((p) => existsSync(p));
22
+ if (!path) {
23
+ throw new Error(`workflow "${pathOrName}" not found in: ${searchDirs.join(', ')}`);
24
+ }
25
+ }
26
+ if (!existsSync(path)) throw new Error(`workflow file not found: ${path}`);
27
+ return {
28
+ doc: JSON.parse(readFileSync(path, 'utf8')),
29
+ path,
30
+ };
31
+ }
32
+
33
+ function newRunId() {
34
+ return `wf-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
35
+ }
36
+
37
+ /**
38
+ * Execute a workflow.
39
+ * @param {object} opts
40
+ * @param {string} opts.bullswarmDir
41
+ * @param {object} opts.doc validated workflow document
42
+ * @param {object} opts.pools live pools (buildPoolsLive)
43
+ * @param {object} opts.inputs runtime inputs (CLI --input k=v)
44
+ * @param {string} [opts.resumeRunId]
45
+ * @param {function} opts.onEvent UX event sink
46
+ */
47
+ export async function runWorkflow(opts) {
48
+ const { bullswarmDir, doc, pools, inputs = {} } = opts;
49
+ const runsRoot = join(bullswarmDir, 'workflows');
50
+ mkdirSync(runsRoot, { recursive: true });
51
+
52
+ const resuming = Boolean(opts.resumeRunId);
53
+ const runId = resuming ? opts.resumeRunId : newRunId();
54
+ const runDir = join(runsRoot, runId);
55
+ mkdirSync(runDir, { recursive: true });
56
+
57
+ let state;
58
+ if (resuming && existsSync(join(runDir, 'state.json'))) {
59
+ state = JSON.parse(readFileSync(join(runDir, 'state.json'), 'utf8'));
60
+ state.resumed = true;
61
+ } else {
62
+ if (resuming) {
63
+ throw new Error(`cannot resume: no state.json for run ${runId}`);
64
+ }
65
+ state = {
66
+ runId,
67
+ workflow: doc.name,
68
+ inputs: { ...Object.fromEntries(
69
+ Object.entries(doc.inputs ?? {}).map(([k, v]) => [k, v.default]),
70
+ ), ...inputs },
71
+ settings: { escalateOnFail: true, concurrency: 4, ...(doc.settings ?? {}) },
72
+ outputs: {},
73
+ steps: [], // linear log: {phase, stepId, type, verdict summary}
74
+ startedAt: new Date().toISOString(),
75
+ resumed: false,
76
+ };
77
+ }
78
+ if (opts.inputs && Object.keys(opts.inputs).length) {
79
+ state.inputs = { ...state.inputs, ...opts.inputs };
80
+ }
81
+
82
+ const runtime = new WorkflowRuntime({
83
+ bullswarmDir,
84
+ pools,
85
+ state,
86
+ runDir,
87
+ onEvent: opts.onEvent,
88
+ });
89
+ runtime.persist();
90
+
91
+ opts.onEvent?.({ type: 'workflow.started', runId, workflow: doc.name, phases: doc.phases.length, resumed: state.resumed });
92
+
93
+ let aborted = false;
94
+ let abortReason = null;
95
+
96
+ for (let pi = 0; pi < doc.phases.length && !aborted; pi++) {
97
+ const phase = doc.phases[pi];
98
+ opts.onEvent?.({ type: 'phase.started', index: pi, total: doc.phases.length, name: phase.name });
99
+ let phaseFailed = false;
100
+
101
+ for (const step of phase.steps ?? []) {
102
+ if (resuming && state.outputs[step.id]?.ok === true && step.type === 'run') {
103
+ opts.onEvent?.({ type: 'step.skipped', stepId: step.id });
104
+ continue;
105
+ }
106
+ let r;
107
+ try {
108
+ r = await runtime.runStep(step);
109
+ } catch (err) {
110
+ // Step-level errors (bad template refs, unparseable fanout items, …)
111
+ // are step failures under onError semantics — never crashes.
112
+ r = { ok: false, why: err.message };
113
+ state.outputs[step.id] = { ok: false, why: err.message };
114
+ }
115
+ state.steps.push({
116
+ phase: phase.name,
117
+ stepId: step.id,
118
+ type: step.type,
119
+ ok: r.ok,
120
+ why: r.why ?? null,
121
+ });
122
+ runtime.persist();
123
+
124
+ if (!r.ok) {
125
+ phaseFailed = true;
126
+ const onError = step.onError ?? 'continue';
127
+ if (onError === 'fail') {
128
+ aborted = true;
129
+ abortReason = `step ${step.id} failed (onError: fail): ${r.why ?? 'unknown'}`;
130
+ break;
131
+ }
132
+ if (onError === 'skip-phase') {
133
+ opts.onEvent?.({ type: 'phase.skipped-rest', phase: phase.name, stepId: step.id });
134
+ break;
135
+ }
136
+ // 'continue' — record and move on
137
+ }
138
+ }
139
+
140
+ if (!aborted && phaseFailed && (doc.settings?.stopOnPhaseFailure || state.settings.stopOnPhaseFailure)) {
141
+ aborted = true;
142
+ abortReason = `phase ${phase.name} had failures (stopOnPhaseFailure)`;
143
+ }
144
+ opts.onEvent?.({ type: 'phase.completed', index: pi, name: phase.name, failed: phaseFailed });
145
+ }
146
+
147
+ const finishedAt = new Date().toISOString();
148
+ state.finishedAt = finishedAt;
149
+ state.status = aborted ? 'failed' : 'completed';
150
+ if (abortReason) state.abortReason = abortReason;
151
+ runtime.persist();
152
+
153
+ const report = buildReport(state, doc, runDir);
154
+ writeFileSync(join(runDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
155
+ opts.onEvent?.({ type: 'workflow.completed', runId, status: state.status, report: report.summary });
156
+
157
+ return { runId, runDir, state, report };
158
+ }
159
+
160
+ export function buildReport(state, doc, runDir) {
161
+ const stepResults = state.steps ?? [];
162
+ const fanoutSteps = Object.entries(state.outputs ?? {}).filter(
163
+ ([, v]) => v && typeof v === 'object' && 'items' in v,
164
+ );
165
+ let fanoutOk = 0;
166
+ let fanoutFailed = 0;
167
+ for (const [, v] of fanoutSteps) {
168
+ fanoutOk += v.ok ?? 0;
169
+ fanoutFailed += v.failed ?? 0;
170
+ }
171
+ const simpleOk = stepResults.filter((s) => s.ok).length;
172
+ const simpleFailed = stepResults.filter((s) => s.ok === false).length;
173
+
174
+ return {
175
+ schemaVersion: 'bullswarm.workflow.report.v1',
176
+ runId: state.runId,
177
+ workflow: state.workflow,
178
+ status: state.status,
179
+ startedAt: state.startedAt,
180
+ finishedAt: state.finishedAt,
181
+ resumed: state.resumed === true,
182
+ abortReason: state.abortReason ?? null,
183
+ summary: {
184
+ stepsTotal: stepResults.length,
185
+ stepsOk: simpleOk,
186
+ stepsFailed: simpleFailed,
187
+ fanoutSteps: fanoutSteps.length,
188
+ fanoutOk,
189
+ fanoutFailed,
190
+ },
191
+ steps: stepResults,
192
+ outputs: state.outputs,
193
+ artifactsDir: runDir,
194
+ };
195
+ }
@@ -0,0 +1,258 @@
1
+ // bullswarm workflow runtime — executes a validated workflow document.
2
+ //
3
+ // Doctrine:
4
+ // R1. Dispatch reuses watchOnce verbatim — same verdict contract, same
5
+ // quarantine side effects, same meter accounting as single runs.
6
+ // R2. State persists to disk after EVERY step; resume = skip ok:true.
7
+ // R3. Escalation is verdict-driven: failed step retries once on next pool
8
+ // by surplus (never the same pool, never more than once).
9
+ // R4. Concurrency limiter is global across fanout expansions.
10
+ // R5. onError: continue | fail (abort run) | skip-phase (rest of phase).
11
+
12
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { pickPool } from '../lib/route.js';
15
+ import { watchOnce } from '../lib/watch.js';
16
+ import { renderDeep, extractItems } from './template.js';
17
+
18
+ export class WorkflowRuntime {
19
+ /**
20
+ * @param {object} opts
21
+ * @param {string} opts.bullswarmDir ~/.bullswarm
22
+ * @param {object} opts.pools buildPoolsLive result pools array
23
+ * @param {object} opts.state loaded workflow state (mutable)
24
+ * @param {string} opts.runDir artifact dir for this run
25
+ * @param {function} opts.onEvent (event) => void for UX rendering
26
+ */
27
+ constructor(opts) {
28
+ this.bullswarmDir = opts.bullswarmDir;
29
+ this.pools = opts.pools;
30
+ this.state = opts.state;
31
+ this.runDir = opts.runDir;
32
+ this.onEvent = opts.onEvent ?? (() => {});
33
+ this.limiter = null; // set from settings at run()
34
+ }
35
+
36
+ persist() {
37
+ writeFileSync(join(this.runDir, 'state.json'), `${JSON.stringify(this.state, null, 2)}\n`);
38
+ }
39
+
40
+ emit(type, payload) {
41
+ this.onEvent({ type, ...payload });
42
+ }
43
+
44
+ scopeFor(step) {
45
+ return {
46
+ inputs: this.state.inputs,
47
+ outputs: this.state.outputs,
48
+ runId: this.state.runId,
49
+ wfDir: this.runDir,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Execute one dispatch (run or single fanout expansion).
55
+ * Returns verdict. Applies escalation per R3.
56
+ */
57
+ async dispatch(step, taskText, targetDir, paths, opts = {}) {
58
+ const attemptPools = this.preparePools(step);
59
+ let lastVerdict = null;
60
+
61
+ for (let attempt = 0; attempt < 2; attempt++) {
62
+ const route = pickPool(step.lane ?? 'chore', attemptPools, {
63
+ callerEligible: false,
64
+ callerSession: false, // workflow context: every pool is a worker
65
+ now: Date.now(),
66
+ });
67
+ if (!route.pick) {
68
+ return {
69
+ ok: false,
70
+ keepOnClaude: false,
71
+ why: `no eligible pool (${route.why})`,
72
+ pick: { pool: null },
73
+ meta: { exitCode: null },
74
+ };
75
+ }
76
+ const connector = route.pick.connector?.connector ?? route.pick.connector;
77
+
78
+ // Pin pool if requested (validation already checked existence)
79
+ const chosen = step.pool
80
+ ? attemptPools.find((p) => p.name === step.pool)
81
+ : connector;
82
+ const conn = (chosen?.connector) ? chosen.connector : chosen;
83
+
84
+ this.emit('step.started', {
85
+ stepId: step.id,
86
+ item: opts.item,
87
+ pool: conn.name,
88
+ attempt,
89
+ });
90
+
91
+ const verdict = await watchOnce(conn, taskText, targetDir, paths, {
92
+ timeoutSec: step.timeoutSec ?? conn.timeoutSec ?? 900,
93
+ });
94
+
95
+ if (verdict.ok || step.pool) {
96
+ // pinned pools don't escalate — you asked for THIS pool
97
+ return verdict;
98
+ }
99
+ lastVerdict = verdict;
100
+ // Escalate: drop the pool that just failed from this step's candidates.
101
+ const failedName = conn.name;
102
+ const idx = attemptPools.findIndex((p) => p.name === failedName);
103
+ if (idx >= 0) attemptPools.splice(idx, 1);
104
+ if (!opts.escalate || attemptPools.length === 0) break;
105
+ this.emit('step.escalate', {
106
+ stepId: step.id, item: opts.item,
107
+ from: failedName, why: verdict.why,
108
+ });
109
+ }
110
+ return lastVerdict;
111
+ }
112
+
113
+ preparePools(step) {
114
+ // Fresh eligible list per dispatch: enabled, not quarantined/exhausted.
115
+ return this.pools.filter((p) => p.enabled !== false && !p.quarantine);
116
+ }
117
+
118
+ async runStep(step) {
119
+ const scope = this.scopeFor(step);
120
+ if (step.type === 'run') {
121
+ return this.runSingle(step, scope);
122
+ }
123
+ if (step.type === 'fanout') {
124
+ return this.runFanout(step, scope);
125
+ }
126
+ throw new Error(`unknown step type ${step.type}`);
127
+ }
128
+
129
+ async runSingle(step, scope) {
130
+ const rendered = renderDeep(
131
+ {
132
+ lane: step.lane ?? 'chore',
133
+ addDir: step.addDir,
134
+ prompt: step.prompt,
135
+ taskFile: step.taskFile,
136
+ },
137
+ scope,
138
+ );
139
+ const taskText = rendered.prompt
140
+ ?? readFileSync(rendered.taskFile, 'utf8');
141
+ const targetDir = rendered.addDir ? String(rendered.addDir).replace(/^~/, process.env.HOME ?? '') : process.cwd();
142
+
143
+ const stamp = `${step.id}-${Date.now().toString(36)}`;
144
+ const paths = {
145
+ taskFile: join(this.runDir, `task-${stamp}.md`),
146
+ outFile: join(this.runDir, `out-${stamp}.md`),
147
+ };
148
+
149
+ const verdict = await this.dispatch(step, taskText, targetDir, paths, {
150
+ escalate: this.state.settings.escalateOnFail !== false,
151
+ });
152
+ this.recordOutput(step.id, verdict, paths);
153
+ return verdict;
154
+ }
155
+
156
+ async runFanout(step, scope) {
157
+ const items = extractItems(this.state, renderTemplate0(step.itemsFrom, scope));
158
+ const concurrency = Math.max(1, Math.min(
159
+ step.concurrency ?? this.state.settings.concurrency ?? 4,
160
+ this.state.settings.concurrency ?? Infinity,
161
+ ));
162
+ const results = new Array(items.length).fill(null);
163
+ let cursor = 0;
164
+ let failures = 0;
165
+ const resumed = this.state.outputs?.[step.id]?.items ?? [];
166
+
167
+ const worker = async () => {
168
+ while (cursor < items.length) {
169
+ const i = cursor++;
170
+ const item = items[i];
171
+
172
+ // Resume: skip items whose saved verdict is ok:true (R2)
173
+ const prev = resumed[i];
174
+ if (prev?.verdict?.ok === true) {
175
+ results[i] = prev;
176
+ this.emit('item.skipped', { stepId: step.id, index: i, item });
177
+ continue;
178
+ }
179
+
180
+ const itemScope = { ...scope, item };
181
+ let template;
182
+ try {
183
+ template = renderDeep(step.stepTemplate, itemScope);
184
+ } catch (err) {
185
+ results[i] = { verdict: { ok: false, why: err.message }, pool: null };
186
+ failures++;
187
+ this.emit('item.failed', { stepId: step.id, index: i, item, why: err.message });
188
+ continue;
189
+ }
190
+
191
+ const stamp = `${step.id}-${i}-${Date.now().toString(36)}`;
192
+ const paths = {
193
+ taskFile: join(this.runDir, `task-${stamp}.md`),
194
+ outFile: join(this.runDir, `out-${stamp}.md`),
195
+ };
196
+
197
+ const targetDir = template.addDir
198
+ ? String(template.addDir).replace(/^~/, process.env.HOME ?? '')
199
+ : process.cwd();
200
+ const taskText = template.prompt
201
+ ?? readFileSync(String(template.taskFile), 'utf8');
202
+
203
+ this.emit('item.started', { stepId: step.id, index: i, total: items.length, item });
204
+ const verdict = await this.dispatch(
205
+ { ...step, id: `${step.id}[${i}]` },
206
+ taskText, targetDir, paths,
207
+ { item, escalate: this.state.settings.escalateOnFail !== false },
208
+ );
209
+ results[i] = { item, verdict, outFile: paths.outFile };
210
+ if (verdict.ok) {
211
+ this.emit('item.completed', { stepId: step.id, index: i, pool: verdict.pick?.pool, wall: verdict.meta?.wallSec });
212
+ } else {
213
+ failures++;
214
+ this.emit('item.failed', { stepId: step.id, index: i, why: verdict.why, pool: verdict.pick?.pool });
215
+ }
216
+ this.persist();
217
+ }
218
+ };
219
+
220
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
221
+
222
+ const oks = results.filter((r) => r?.verdict?.ok === true).length;
223
+ this.state.outputs[step.id] = {
224
+ total: items.length,
225
+ ok: oks,
226
+ failed: items.length - oks,
227
+ items: results,
228
+ };
229
+ this.persist();
230
+ return { ok: failures === 0, results };
231
+ }
232
+
233
+ recordOutput(stepId, verdict, paths) {
234
+ let outputText = null;
235
+ try {
236
+ if (paths.outFile && existsSync(paths.outFile)) {
237
+ outputText = readFileSync(paths.outFile, 'utf8');
238
+ }
239
+ } catch { /* non-fatal */ }
240
+ this.state.outputs[stepId] = {
241
+ ok: verdict.ok,
242
+ pool: verdict.pick?.pool ?? null,
243
+ why: verdict.why,
244
+ outFile: paths.outFile,
245
+ wallSec: verdict.meta?.wallSec,
246
+ outputText,
247
+ };
248
+ this.persist();
249
+ }
250
+ }
251
+
252
+ function renderTemplate0(str, scope) {
253
+ return str.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, ref) => {
254
+ const v = ref.trim().split('.').reduce((acc, k) => (acc == null ? undefined : acc[k]), scope);
255
+ if (v === undefined) throw new Error(`unresolved ref {{${ref.trim()}}}`);
256
+ return typeof v === 'string' ? v : JSON.stringify(v);
257
+ });
258
+ }
@@ -0,0 +1,76 @@
1
+ // bullswarm workflow — {{ref}} templating + JSON-path access.
2
+ //
3
+ // Scope precedence: loop item > inputs > outputs.<stepId> > run metadata.
4
+ // A missing reference throws (validation should have caught static cases;
5
+ // this catches runtime-only gaps like fanout item field typos).
6
+
7
+ export function getPath(obj, path) {
8
+ return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
9
+ }
10
+
11
+ /**
12
+ * Expand {{ref}} tokens in a string against a scope object.
13
+ * Supports {{item}}, {{item.path.to.field}}, {{inputs.x}}, {{outputs.stepId}},
14
+ * {{runId}}, {{wfDir}}. Non-string values are JSON-stringified.
15
+ */
16
+ export function renderTemplate(str, scope) {
17
+ if (typeof str !== 'string') return str;
18
+ return str.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, ref) => {
19
+ const v = getPath(scope, ref.trim());
20
+ if (v === undefined) {
21
+ throw new Error(`template ref "{{${ref.trim()}}}" unresolved at render time`);
22
+ }
23
+ return typeof v === 'string' ? v : JSON.stringify(v);
24
+ });
25
+ }
26
+
27
+ /** Deep-render every string in a step-like object. */
28
+ export function renderDeep(obj, scope) {
29
+ if (typeof obj === 'string') return renderTemplate(obj, scope);
30
+ if (Array.isArray(obj)) return obj.map((v) => renderDeep(v, scope));
31
+ if (obj && typeof obj === 'object') {
32
+ const out = {};
33
+ for (const [k, v] of Object.entries(obj)) out[k] = renderDeep(v, scope);
34
+ return out;
35
+ }
36
+ return obj;
37
+ }
38
+
39
+ /**
40
+ * Extract the items array for a fanout from workflow state by dotted path.
41
+ * Accepts:
42
+ * - a real array (inputs.files, outputs.step.items)
43
+ * - an outputs entry with outputText containing a JSON array
44
+ * (the common case: a discover step returns "[\"a.json\", ...]")
45
+ */
46
+ export function extractItems(state, itemsFrom) {
47
+ const v = getPath(state, itemsFrom);
48
+ if (v === undefined) {
49
+ throw new Error(`fanout itemsFrom "${itemsFrom}" not found in workflow state`);
50
+ }
51
+ if (Array.isArray(v)) return v;
52
+
53
+ // outputs.<stepId> envelope: try its recorded output text
54
+ if (v && typeof v === 'object' && typeof v.outputText === 'string') {
55
+ const parsed = parseJsonArray(v.outputText);
56
+ if (parsed) return parsed;
57
+ throw new Error(
58
+ `fanout itemsFrom "${itemsFrom}": step output is not a JSON array. ` +
59
+ 'The discover step must return ONLY a JSON array of items.',
60
+ );
61
+ }
62
+ throw new Error(`fanout itemsFrom "${itemsFrom}" must resolve to an array (got ${typeof v})`);
63
+ }
64
+
65
+ /** Parse the first JSON array found in a text blob, tolerating prose around it. */
66
+ export function parseJsonArray(text) {
67
+ const start = text.indexOf('[');
68
+ const end = text.lastIndexOf(']');
69
+ if (start === -1 || end <= start) return null;
70
+ try {
71
+ const arr = JSON.parse(text.slice(start, end + 1));
72
+ return Array.isArray(arr) ? arr : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,236 @@
1
+ // bullswarm workflow terminal UX — renders runtime events Claude-style.
2
+ //
3
+ // Marks: ✓ ok · ✗ fail · ⟡ running · ⋈ blocked · ⏭ skipped · ⏳ phase
4
+ // Non-TTY: emits compact JSONL lines instead (machine-consumable).
5
+ // Zero dependencies: plain ANSI, cursor-rewrites only for the spinner.
6
+
7
+ const MARK = {
8
+ ok: '✓',
9
+ fail: '✗',
10
+ running: '⟡',
11
+ blocked: '⋈',
12
+ skipped: '⏭',
13
+ pending: '·',
14
+ };
15
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
16
+
17
+ const DIM = '\x1b[2m';
18
+ const BOLD = '\x1b[1m';
19
+ const GREEN = '\x1b[32m';
20
+ const RED = '\x1b[31m';
21
+ const YELLOW = '\x1b[33m';
22
+ const CYAN = '\x1b[36m';
23
+ const RESET = '\x1b[0m';
24
+
25
+ function c(code, s) { return `${code}${s}${RESET}`; }
26
+ function fmtSecs(ms) {
27
+ if (ms == null) return '—';
28
+ const s = ms / 1000;
29
+ return s >= 60 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${s.toFixed(1)}s`;
30
+ }
31
+
32
+ export class WorkflowTui {
33
+ constructor({ quiet = false, json = false } = {}) {
34
+ this.quiet = quiet;
35
+ this.json = json;
36
+ this.isTTY = process.stdout.isTTY === true;
37
+ this.phaseIndex = 0;
38
+ this.phaseTotal = 0;
39
+ this.phaseName = '';
40
+ this.spinnerTimer = null;
41
+ this.spinFrame = 0;
42
+ this.liveLine = null; // current in-flight line to rewrite
43
+ this.counts = { ok: 0, fail: 0 };
44
+ this.startedAt = null;
45
+ if (!this.json && !this.isTTY) {
46
+ // Non-TTY human mode: no ANSI colors (plain marks still readable)
47
+ this.color = false;
48
+ }
49
+ }
50
+
51
+ c(code, s) { return this.isTTY ? c(code, s) : s; }
52
+
53
+ startSpinner() {
54
+ if (!this.isTTY || this.json || this.spinnerTimer) return;
55
+ this.spinnerTimer = setInterval(() => {
56
+ this.spinFrame = (this.spinFrame + 1) % SPINNER_FRAMES.length;
57
+ if (this.liveLine) this.rewriteLive(this.liveLine.render());
58
+ }, 120);
59
+ }
60
+
61
+ stopSpinner() {
62
+ if (this.spinnerTimer) { clearInterval(this.spinnerTimer); this.spinnerTimer = null; }
63
+ }
64
+
65
+ rewriteLive(line) {
66
+ if (!this.isTTY) return;
67
+ process.stdout.write(`\r\x1b[K${line}`);
68
+ }
69
+
70
+ commitLive() {
71
+ if (this.liveLine) {
72
+ if (this.isTTY) process.stdout.write('\n');
73
+ this.liveLine = null;
74
+ }
75
+ }
76
+
77
+ print(line) {
78
+ if (this.json) return;
79
+ if (this.quiet && !line.includes('summary')) return;
80
+ this.commitLive();
81
+ console.log(line);
82
+ }
83
+
84
+ handle(event) {
85
+ switch (event.type) {
86
+ case 'workflow.started': {
87
+ this.startedAt = Date.now();
88
+ if (this.json) {
89
+ console.log(JSON.stringify({ ev: 'workflow.started', ...event }));
90
+ return;
91
+ }
92
+ const resumed = event.resumed ? this.c(YELLOW, ' (resumed)') : '';
93
+ this.print('');
94
+ this.print(this.c(BOLD, `bullswarm workflow · ${event.workflow} · run ${event.runId}`) + resumed);
95
+ this.print(this.c(DIM, `─`.repeat(58)));
96
+ break;
97
+ }
98
+ case 'phase.started': {
99
+ this.phaseIndex = event.index + 1;
100
+ this.phaseTotal = event.total;
101
+ this.phaseName = event.name;
102
+ if (this.json) {
103
+ console.log(JSON.stringify({ ev: 'phase.started', ...event }));
104
+ return;
105
+ }
106
+ this.print('');
107
+ this.print(this.c(CYAN, `▐ phase ${event.index + 1}/${event.total} · ${event.name}`));
108
+ break;
109
+ }
110
+ case 'step.started': {
111
+ if (this.json) {
112
+ console.log(JSON.stringify({ ev: 'step.started', ...event }));
113
+ return;
114
+ }
115
+ const label = event.item != null ? `${event.stepId}[${labelOf(event.item)}]` : event.stepId;
116
+ this.commitLive();
117
+ this.liveLine = makeLiveLine(MARK.running, label, event.pool, this);
118
+ this.rewriteLive(this.liveLine.render());
119
+ this.startSpinner();
120
+ break;
121
+ }
122
+ case 'item.started': {
123
+ if (this.json) {
124
+ console.log(JSON.stringify({ ev: 'item.started', ...event }));
125
+ return;
126
+ }
127
+ const idx = `${event.index + 1}/${event.total ?? '?'}`;
128
+ const label = `${event.stepId}[${idx}]`;
129
+ this.commitLive();
130
+ this.liveLine = makeLiveLine(MARK.running, label, event.pool ?? '…', this, itemPreview(event.item));
131
+ this.rewriteLive(this.liveLine.render());
132
+ this.startSpinner();
133
+ break;
134
+ }
135
+ case 'item.completed': {
136
+ this.counts.ok++;
137
+ if (this.json) {
138
+ console.log(JSON.stringify({ ev: 'item.completed', ...event }));
139
+ return;
140
+ }
141
+ const label = `${event.stepId}[${event.index + 1}]`;
142
+ this.commitLive();
143
+ this.print(
144
+ ` ${this.c(GREEN, MARK.ok)} ${label.padEnd(34)} ${this.c(DIM, String(event.pool ?? '').padEnd(14))} ${fmtSecs(event.wall * 1000)} ok`
145
+ );
146
+ break;
147
+ }
148
+ case 'item.failed': {
149
+ this.counts.fail++;
150
+ if (this.json) {
151
+ console.log(JSON.stringify({ ev: 'item.failed', ...event }));
152
+ return;
153
+ }
154
+ const label = `${event.stepId}[${event.index + 1}]`;
155
+ this.commitLive();
156
+ const pool = event.pool ? `${event.pool}` : '—';
157
+ this.print(
158
+ ` ${this.c(RED, MARK.fail)} ${label.padEnd(34)} ${pool.padEnd(14)} fail · ${(event.why ?? '').slice(0, 48)}`
159
+ );
160
+ break;
161
+ }
162
+ case 'step.escalate': {
163
+ if (this.json) {
164
+ console.log(JSON.stringify({ ev: 'step.escalate', ...event }));
165
+ return;
166
+ }
167
+ this.commitLive();
168
+ this.print(` ${this.c(YELLOW, '↳ escalate')} ${event.from} → next surplus pool (${(event.why ?? '').slice(0, 40)})`);
169
+ break;
170
+ }
171
+ case 'step.skipped': {
172
+ if (this.json) { console.log(JSON.stringify({ ev: 'step.skipped', ...event })); return; }
173
+ this.print(` ${this.c(YELLOW, MARK.skipped)} ${event.stepId.padEnd(34)} ${this.c(DIM, 'ok from previous run (resume)')}`);
174
+ break;
175
+ }
176
+ case 'phase.skipped-rest': {
177
+ if (this.json) { console.log(JSON.stringify({ ev: 'phase.skipped-rest', ...event })); return; }
178
+ this.commitLive();
179
+ this.print(` ${this.c(YELLOW, MARK.skipped)} rest of phase "${event.phase}" skipped after ${event.stepId}`);
180
+ break;
181
+ }
182
+ case 'phase.completed': {
183
+ this.stopSpinner();
184
+ this.commitLive();
185
+ if (this.json) { console.log(JSON.stringify({ ev: 'phase.completed', ...event })); return; }
186
+ const mark = event.failed ? this.c(RED, MARK.fail) : this.c(GREEN, MARK.ok);
187
+ this.print(`${mark} phase ${this.phaseIndex}/${this.phaseTotal} · ${event.name} done${event.failed ? this.c(YELLOW, ' (with failures)') : ''}`);
188
+ break;
189
+ }
190
+ case 'workflow.completed': {
191
+ this.stopSpinner();
192
+ this.commitLive();
193
+ if (this.json) {
194
+ console.log(JSON.stringify({ ev: 'workflow.completed', ...event }));
195
+ return;
196
+ }
197
+ const elapsed = this.startedAt ? fmtSecs(Date.now() - this.startedAt) : '—';
198
+ const s = event.report ?? {};
199
+ this.print('');
200
+ this.print(this.c(DIM, '─'.repeat(58)));
201
+ const status = event.status === 'completed'
202
+ ? this.c(GREEN, '✓ completed')
203
+ : this.c(RED, '✗ failed');
204
+ this.print(
205
+ `${status} steps ✓${s.stepsOk ?? 0}/✗${s.stepsFailed ?? 0} · fanout ✓${s.fanoutOk ?? 0}/✗${s.fanoutFailed ?? 0} · elapsed ${elapsed}`
206
+ );
207
+ if (event.runId) this.print(this.c(DIM, `report: ~/.bullswarm/workflows/${event.runId}/report.json`));
208
+ break;
209
+ }
210
+ default:
211
+ if (this.json) console.log(JSON.stringify({ ev: event.type, ...event }));
212
+ }
213
+ }
214
+ }
215
+
216
+ function makeLiveLine(markChar, label, pool, tui, preview) {
217
+ const start = Date.now();
218
+ const render = () => {
219
+ const frame = tui.isTTY ? SPINNER_FRAMES[tui.spinFrame] : MARK.pending;
220
+ const secs = ((Date.now() - start) / 1000).toFixed(0);
221
+ const pv = preview ? ` ${tui.c(DIM, preview)}` : '';
222
+ return ` ${tui.c(CYAN, frame)} ${String(label).padEnd(34)} ${String(pool).padEnd(14)} ${secs}s running${pv}`;
223
+ };
224
+ return { render };
225
+ }
226
+
227
+ function itemPreview(item) {
228
+ if (item == null) return '';
229
+ const s = typeof item === 'string' ? item : JSON.stringify(item);
230
+ return s.length > 40 ? `${s.slice(0, 38)}…` : s;
231
+ }
232
+
233
+ function labelOf(item) {
234
+ const s = typeof item === 'string' ? item : JSON.stringify(item);
235
+ return s.length > 20 ? `${s.slice(0, 18)}…]` : s;
236
+ }
@@ -0,0 +1,205 @@
1
+ // bullswarm workflow — schema validation for dynamic workflow documents.
2
+ //
3
+ // Doctrine:
4
+ // W1. A workflow is a JSON document, validated fully BEFORE anything runs.
5
+ // W2. Template references must resolve at validation time, except
6
+ // {{item}} / {{item.*}} inside fanout stepTemplate (per-expansion).
7
+ // W3. Lanes and pinned pools are checked against the live registry so a
8
+ // typo never burns quota discovering itself mid-run.
9
+
10
+ const LANES = ['analyze', 'build', 'chore'];
11
+ const ON_ERROR = ['continue', 'fail', 'skip-phase'];
12
+ const STEP_TYPES = ['run', 'fanout'];
13
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
14
+
15
+ export class WorkflowValidationError extends Error {
16
+ constructor(issues) {
17
+ super(`workflow invalid: ${issues.length} problem(s)`);
18
+ this.issues = issues;
19
+ }
20
+ }
21
+
22
+ function collect(issues, ok, msg) {
23
+ if (!ok) issues.push(msg);
24
+ return ok;
25
+ }
26
+
27
+ /** Extract {{ref}} tokens from a string. */
28
+ export function templateRefs(str) {
29
+ const out = [];
30
+ const re = /\{\{\s*([^}]+?)\s*\}\}/g;
31
+ let m;
32
+ while ((m = re.exec(str)) !== null) out.push(m[1].trim());
33
+ return out;
34
+ }
35
+
36
+ function walkStrings(value, fn) {
37
+ if (typeof value === 'string') {
38
+ fn(value);
39
+ } else if (Array.isArray(value)) {
40
+ for (const v of value) walkStrings(v, fn);
41
+ } else if (value && typeof value === 'object') {
42
+ for (const v of Object.values(value)) walkStrings(v, fn);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Validate a workflow document against the known lanes/pools.
48
+ * @param {object} wf parsed workflow JSON
49
+ * @param {{lanes?: string[], poolNames?: string[]}} env live registry info
50
+ * @returns {{name: string, warnings: string[]}} normalized doc
51
+ * @throws WorkflowValidationError with .issues[] on any problem
52
+ */
53
+ export function validateWorkflow(wf, { lanes = LANES, poolNames = [] } = {}) {
54
+ const issues = [];
55
+ const warnings = [];
56
+ const validLanes = new Set(lanes);
57
+ const validPools = new Set(poolNames);
58
+
59
+ if (!wf || typeof wf !== 'object' || Array.isArray(wf)) {
60
+ throw new WorkflowValidationError(['document is not a JSON object']);
61
+ }
62
+
63
+ collect(issues, typeof wf.name === 'string' && NAME_RE.test(wf.name),
64
+ `name "${wf.name}" must be kebab-case (letters/digits/dashes)`);
65
+ collect(issues, typeof wf.description === 'string' && wf.description.length > 0,
66
+ 'description is required');
67
+ collect(issues, Array.isArray(wf.phases) && wf.phases.length > 0,
68
+ 'phases must be a non-empty array');
69
+
70
+ // inputs
71
+ const inputs = wf.inputs ?? {};
72
+ if (!collect(issues, inputs && typeof inputs === 'object' && !Array.isArray(inputs),
73
+ 'inputs must be an object')) {
74
+ throw new WorkflowValidationError(issues);
75
+ }
76
+ for (const [k, v] of Object.entries(inputs)) {
77
+ collect(issues, v && typeof v === 'object', `input "${k}" must be an object`);
78
+ }
79
+
80
+ // phases
81
+ const phaseNames = new Set();
82
+ const stepIds = new Set();
83
+ const outputs = new Set(); // step ids that produce outputs
84
+
85
+ (wf.phases ?? []).forEach((phase, pi) => {
86
+ const at = `phases[${pi}]`;
87
+ collect(issues, phase && typeof phase === 'object', `${at} must be an object`);
88
+ if (!phase || typeof phase !== 'object') return;
89
+ collect(issues, typeof phase.name === 'string' && NAME_RE.test(phase.name),
90
+ `${at}.name must be kebab-case`);
91
+ if (phase.name) {
92
+ collect(issues, !phaseNames.has(phase.name), `duplicate phase name "${phase.name}"`);
93
+ phaseNames.add(phase.name);
94
+ }
95
+ collect(issues, Array.isArray(phase.steps), `${at}.steps must be an array`);
96
+
97
+ (phase.steps ?? []).forEach((step, si) => {
98
+ const sat = `${at}.steps[${si}]`;
99
+ if (!collect(issues, step && typeof step === 'object', `${sat} must be an object`)) return;
100
+ collect(issues, typeof step.id === 'string' && NAME_RE.test(step.id),
101
+ `${sat}.id must be kebab-case`);
102
+ if (step.id) {
103
+ collect(issues, !stepIds.has(step.id), `duplicate step id "${step.id}"`);
104
+ stepIds.add(step.id);
105
+ outputs.add(step.id);
106
+ }
107
+ collect(issues, STEP_TYPES.includes(step.type),
108
+ `${sat}.type must be one of ${STEP_TYPES.join('|')} (got "${step.type}")`);
109
+ collect(issues, ON_ERROR.includes(step.onError ?? 'continue'),
110
+ `${sat}.onError must be one of ${ON_ERROR.join('|')}`);
111
+
112
+ if (step.lane != null) {
113
+ collect(issues, validLanes.has(step.lane),
114
+ `${sat}.lane "${step.lane}" is not a lane (${[...validLanes].join(', ')})`);
115
+ }
116
+ if (step.pool != null) {
117
+ collect(issues, validPools.has(step.pool),
118
+ `${sat}.pool "${step.pool}" is not a known pool (${[...validPools].join(', ') || 'none discovered'})`);
119
+ }
120
+
121
+ if (step.type === 'fanout') {
122
+ collect(issues, typeof step.itemsFrom === 'string' && step.itemsFrom.length > 0,
123
+ `${sat}.itemsFrom is required for fanout steps`);
124
+ // itemsFrom must reference declared inputs or a prior step's output
125
+ if (typeof step.itemsFrom === 'string' && step.itemsFrom.includes('.')) {
126
+ const [root, target] = step.itemsFrom.split('.');
127
+ collect(issues, root === 'inputs' || (root === 'outputs' && outputs.has(target)),
128
+ `${sat}.itemsFrom "${step.itemsFrom}" cannot resolve (use inputs.<name> or outputs.<priorStepId>)`);
129
+ } else if (typeof step.itemsFrom === 'string') {
130
+ collect(issues, false,
131
+ `${sat}.itemsFrom "${step.itemsFrom}" must be a dotted path (inputs.<name> or outputs.<priorStepId>)`);
132
+ }
133
+ collect(issues, step.stepTemplate && typeof step.stepTemplate === 'object',
134
+ `${sat}.stepTemplate is required for fanout steps`);
135
+ if (step.concurrency != null) {
136
+ collect(issues, Number.isInteger(step.concurrency) && step.concurrency >= 1,
137
+ `${sat}.concurrency must be a positive integer`);
138
+ }
139
+ } else if (step.type === 'run') {
140
+ collect(issues,
141
+ typeof step.taskFile === 'string' || typeof step.prompt === 'string',
142
+ `${sat} needs taskFile or prompt`);
143
+ }
144
+
145
+ if (step.timeoutSec != null) {
146
+ collect(issues, Number.isFinite(step.timeoutSec) && step.timeoutSec > 0,
147
+ `${sat}.timeoutSec must be a positive number`);
148
+ }
149
+ });
150
+ });
151
+
152
+ if (issues.length) throw new WorkflowValidationError(issues);
153
+
154
+ // ---- template reference resolution (W2) --------------------------------
155
+ // Scope available at validation: inputs.*, outputs.<stepId> for PRIOR
156
+ // steps, runId/wfDir metadata. {{item}} allowed only inside fanout
157
+ // stepTemplate. We do a second pass now that all step ids are known.
158
+
159
+ const resolvable = (ref, inTemplate) => {
160
+ const root = ref.split('.')[0];
161
+ if (root === 'item') return inTemplate;
162
+ if (root === 'inputs') return true; // presence checked at runtime vs declared inputs? keep lenient, warn below
163
+ if (root === 'outputs') {
164
+ const target = ref.split('.')[1];
165
+ return target ? outputs.has(target) : false;
166
+ }
167
+ return root === 'runId' || root === 'wfDir';
168
+ };
169
+
170
+ const checkRefs = (obj, inTemplate, label) => {
171
+ walkStrings(obj, (s) => {
172
+ for (const ref of templateRefs(s)) {
173
+ if (!resolvable(ref, inTemplate)) {
174
+ issues.push(`${label}: template ref "{{${ref}}}" cannot resolve` +
175
+ (inTemplate ? '' : ` (known roots: inputs, outputs.<stepId>, runId, wfDir)`));
176
+ }
177
+ }
178
+ });
179
+ };
180
+
181
+ (wf.phases ?? []).forEach((phase, pi) => {
182
+ (phase.steps ?? []).forEach((step, si) => {
183
+ const sat = `phases[${pi}].steps[${si}](${step.id ?? '?'})`;
184
+ const { stepTemplate, ...rest } = step;
185
+ checkRefs(rest, false, sat);
186
+ if (stepTemplate) checkRefs(stepTemplate, true, `${sat}.stepTemplate`);
187
+ });
188
+ });
189
+
190
+ // undeclared input usage → warning only (inputs may be passed at runtime)
191
+ const usedInputs = new Set();
192
+ walkStrings(wf, (s) => {
193
+ for (const ref of templateRefs(s)) {
194
+ if (ref.startsWith('inputs.')) usedInputs.add(ref.slice('inputs.'.length));
195
+ }
196
+ });
197
+ for (const u of usedInputs) {
198
+ if (!(u in inputs)) {
199
+ warnings.push(`template uses inputs.${u} but it is not declared under "inputs" (pass --input ${u}=…)`);
200
+ }
201
+ }
202
+
203
+ if (issues.length) throw new WorkflowValidationError(issues);
204
+ return { name: wf.name, warnings };
205
+ }