bullswarm 0.16.0 → 0.17.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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Workflow timeline (PR #5) hardened after a 16-agent adversarial review against
6
+ real run state (23 findings, 21 confirmed): worker rows now name their phase
7
+ (`├─✓ [Verify] verify-impl`) because concurrent phases interleave in time
8
+ order and the tree glyph alone hung a row under the wrong phase; a phase whose
9
+ actions never started (a blocked tail) is shown as `[Phase: X] blocked`
10
+ instead of vanishing; the header line is truncated so widths down to 20
11
+ columns really hold; PgUp now scrolls the timeline to earlier rows (it was a
12
+ dead key at the newest view) and scroll state resets when the pane changes;
13
+ below 100 columns the footer and status line no longer advertise a timeline
14
+ the narrow layout does not render. Confirmed minors left open are listed on
15
+ PR #5.
16
+ - Reworked the autonomous workflow TUI around a human-readable execution story:
17
+ the existing Workflow Planner and phase sidebar now sits beside a timestamped
18
+ timeline of completed preflight, planner, phase, and worker milestones; active
19
+ workers and the waiting/running planner are isolated in a Live section with
20
+ their latest normalized action and stream heartbeat, and future work stays in
21
+ a distinct Next section. `v` keeps raw action-ledger and event evidence one key
22
+ away without mixing it into the default view.
23
+ - Rule 7: a verify checks the goal's own acceptance criteria and never adds a
24
+ process rule the goal does not state (append-only, existing tests
25
+ untouched); when the implementation changes what an existing assertion
26
+ pins, a worker must own updating it. Earned three times on goal 4 (attempt
27
+ 3, `r2vu9i`, `euh622`): the planner wrote "EXTEND BY APPENDING only" into
28
+ the test worker and its verify while the goal said "do NOT modify existing
29
+ tests except to extend them" and item 5 forced `programFeatures` to grow,
30
+ so the assertion at `workflow-adaptive.test.js:206` had no owner, the
31
+ re-verify rejected the mandated extension, and a planner turn recovered.
32
+ - Goal-4 rerun on v0.16.0 (`euh622`): 36 min 00 s, four stage phases in the TUI
33
+ (implement, tests, verify, report) instead of sixteen one-action rows, 22/24
34
+ dispatches on `kaihk/gpt-5.6-luna`, auto-completed, 319/319; two planner
35
+ turns because of the false rejection above (planner turn 2: "an append-only
36
+ rule that the goal itself makes unsatisfiable").
37
+
3
38
  ## 0.16.0 — the planner sets the width; a re-verify judges the repair
4
39
 
5
40
  - A re-verify after a repair round now receives the concerns it raised and the
package/README.md CHANGED
@@ -289,14 +289,19 @@ bullswarm workflow watch <shortId> --verbose # detailed agent/action view
289
289
  ```
290
290
 
291
291
  `workflow tui` is the interactive, Claude-style `/workflows` view. For an
292
- autonomous goal its left navigation stacks a compact Orchestrator panel above
293
- the Phases panel; internal planner turns never appear as workers or phases.
294
- Select Orchestration and press Enter, or press `o`
295
- anywhere, to open a summary-first orchestration overview: what it is doing now,
292
+ autonomous goal its left navigation stacks a compact Workflow Planner panel
293
+ above the Phases panel; internal planner turns never appear as workers or phases.
294
+ The default desktop main panel is a timestamped workflow timeline: completed
295
+ preflight, planner-checkpoint, phase-transition, and worker-result events stay
296
+ above a live section containing the waiting/running Workflow Planner and workers,
297
+ each with its latest semantic action and stream heartbeat. Planned work is kept
298
+ in a separate Next section so it cannot be mistaken for execution evidence.
299
+ Select Workflow Planner and press Enter, or press `o`
300
+ anywhere, to open a summary-first planner overview: what it is doing now,
296
301
  its latest decision in plain language, why it chose that path, what happens
297
- next, progress, and the last three semantic actions. Press `v` to toggle the
298
- technical view with provider session, every checkpoint turn, usage, prompt,
299
- and artifact paths. Status marks are consistent throughout the tree: `○` not started,
302
+ next, progress, and the last three semantic actions. Press `v` from the timeline
303
+ for workflow technical state, or from Workflow Planner for provider session,
304
+ every checkpoint turn, usage, prompt, and artifact paths. Status marks are consistent throughout the tree: `○` not started,
300
305
  an animated Braille spinner for active work, `⧖` waiting, `✓` finished, and
301
306
  `✗` failed or interrupted. The non-emoji `⧖` avoids the inconsistent cell
302
307
  width of `⌛` across terminal fonts. It watches ongoing runs from disk and supports `j`/`k` or arrow-key selection, Enter for
@@ -307,3 +307,47 @@ is met: each round fixed a defect the deliverable needed fixed. Prediction befor
307
307
  rounds and the 10-min recovery turn vanish, wall ≈ 30 min"; observed 25 min 13 s with one planner turn.
308
308
 
309
309
  Cost: 22 of 23 dispatches on the unmetered opencode2 seat; Claude quota spent on one 247 s planner turn.
310
+
311
+ ## Goal-4 rerun on v0.16.0 (phase = stage) — `euh622` (wf-mtej85ws-18a3c0), 15:25:14 → 16:01:14 Z — **36 min 00 s, auto-completed; one planner-authored false rejection cost the recovery turn**
312
+
313
+ Runtime `4bfd7f4` = released v0.16.0 (rule 3 "a phase is a pipeline stage … never one per action"); workers pinned to
314
+ `opencode2`/`kaihk/gpt-5.6-luna` (cleared after); orchestrator `claude-code`/`claude-opus-5`; fresh fixture `g4-bs-v5`.
315
+
316
+ | metric | `r2vu9i` | `bizp4s` | **`euh622`** |
317
+ | --- | ---: | ---: | ---: |
318
+ | wall | 36 min 58 s | 25 min 13 s | 36 min 00 s (2 157 s) |
319
+ | planner turns / plannerSec | 2 / 648 s | 1 / 247 s | 2 / 728 s (34 %) — 462 s + 266 s |
320
+ | dispatches | 26 | 23 | 24 (22 luna + 2 opus) |
321
+ | max concurrent / parallelism | 4 / 1.55 | 4 / 1.77 | 4 / 1.5 |
322
+ | phases in the TUI | 19 one-action rows | 16 one-action rows | **4 stages** (implement 2, tests 3, verify 6 + repairs, report 2) + 2 recovery phases |
323
+ | repairs (rounds / repaired ok / re-verify rejected) | 4 / 2 / 2 | 3 / 2 / 1 | 4 / 2 / 2 |
324
+ | tests after | 314 | 319 | **319/319**; existing tests +116/−1 (the mandated `:206` extension) and +153/−0 |
325
+
326
+ What the phase change did: the planner wrote `implement` (impl ∥ docs), `tests` (three test writers), `verify` (six
327
+ verifies), `report` — the layout asked for, with no scheduling change (impl ∥ docs started together; three test writers
328
+ and verify-impl started the second impl landed; verify-docs ran while impl was still running). Width was one docs
329
+ worker (three files merged; off the critical path) but three test writers — comparable to `bizp4s`.
330
+
331
+ The four repair rounds:
332
+ - `verify-impl` r1: fan-out items dispatched through plain `dispatch()`, so `stepTemplate.outputSchema` was never applied
333
+ — real. Re-verify rejected: the repair tested `step.outputSchema` instead of `itemStep.outputSchema` — the listed
334
+ concern still unresolved, exactly the rejection the re-verify rule permits. r2 repaired; re-verify ok.
335
+ - `verify-test-runtime` r1: the retry case did not assert the `errors` payload of `action.output_schema_retry` — real.
336
+ - `verify-test-refs` r1: the acceptance command failed on the pre-existing assertion at `workflow-adaptive.test.js:206`
337
+ (`programFeatures` pinned to three entries) because goal item 5 mandates adding `outputSchema` to it. The repair
338
+ extended the assertion (+116/−1). Re-verify rejected BECAUSE an existing assertion changed — the planner had written
339
+ "EXTEND BY APPENDING new test cases only" into `test-refs` and "shows APPENDED cases only" into `verify-test-refs`,
340
+ and "Do NOT modify existing tests" into `impl`, while the goal says "do NOT modify existing tests except to extend
341
+ them". No worker owned the assertion; the verifier treated its prompt's rule as an unresolved concern. `verify-suite`,
342
+ `report`, `verify-report` blocked → planner turn 2 (266 s), whose reason is exact: "verify-test-refs ended ok:false
343
+ on an append-only rule that the goal itself makes unsatisfiable — goal item 5 mandates adding 'outputSchema' to
344
+ programFeatures". Recovery program `verify-suite-full` → `final-report` → `verify-final-report`, auto-completed.
345
+ Third occurrence of this shape (attempt 3, `r2vu9i`, here); `bizp4s` avoided it only because its implementation
346
+ happened to keep the old assertion true.
347
+
348
+ Cost of the false rejection: the 266 s planner turn plus the serialised tail ≈ 5–6 min; the rest of the gap to
349
+ `bizp4s` is variance (planner turn 1 462 s vs 247 s on the same contract; `impl` 445 s vs 377 s).
350
+
351
+ Fix committed after the run, unreleased (`71960ae`): rule 7 — "A verify checks the goal's own acceptance criteria …
352
+ never add a process rule the goal does not state (append-only, tests untouched); when the implementation changes what
353
+ an existing assertion pins, a worker must own updating it." Proof pending a rerun on that commit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.16.0",
3
+ "version": "0.17.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/skill/SKILL.md CHANGED
@@ -277,8 +277,12 @@ supports explicit model selection, Bullswarm pins an allowed model in the same
277
277
  effort tier; otherwise that pool is excluded because its implicit default
278
278
  cannot be guaranteed. Restore eligibility with `strategy include-model`.
279
279
 
280
- In the human TUI, the autonomous orchestrator has a compact selectable panel
281
- stacked above the phase tree. Select it and press Enter, or press
280
+ In the human TUI, the autonomous orchestrator is presented as Workflow Planner
281
+ in a compact selectable panel stacked above the phase tree. The default desktop
282
+ view pairs that unchanged navigation with a timestamped workflow timeline:
283
+ finished events stay above a live Planner/worker section with each participant's
284
+ latest semantic action, while unexecuted work stays in a separate Next section.
285
+ Select Workflow Planner and press Enter, or press
282
286
  `o`, to see a summary-first overview of its current role, latest decision,
283
287
  reason, next action, progress, and recent semantic activity. Press `v` for the
284
288
  durable provider session, checkpoint prompts and turns, usage, and artifact
package/src/help.js CHANGED
@@ -676,8 +676,9 @@ const workflowInspectText = rich({
676
676
 
677
677
  const workflowTuiText = rich({
678
678
  usage: 'bullswarm workflow tui [<runId>] [--json] [--all] [--show <runId>] [--cancel <runId>]',
679
- purpose: 'Open the interactive full-screen phase agent step/detail browser for ongoing '
680
- + 'and historical runs, or print a static/JSON snapshot for a non-interactive caller.',
679
+ purpose: 'Open the interactive full-screen workflow timeline with Workflow Planner, phase, '
680
+ + 'live-agent, and technical drill-down views for ongoing and historical runs, or print a '
681
+ + 'static/JSON snapshot for a non-interactive caller.',
681
682
  args: [{ name: '[<runId>]', desc: 'shortId or runId to open directly in detail view; omit to see the run picker' }],
682
683
  options: [
683
684
  { flag: '--json', desc: "print a JSON snapshot instead of opening the interactive browser (list of ongoing runs, or one run's state/report/events when a runId is given)", default: "opens the interactive browser on a TTY; without a TTY, a given runId instead prints one static text detail tree" },
@@ -689,6 +690,7 @@ const workflowTuiText = rich({
689
690
  'interactive mode and the --json/--show/--all views are read-only',
690
691
  '--cancel writes state.json (cancelRequested=true, status=cancelling) — cooperative, not a force-kill: the workflow stops at its next safe checkpoint',
691
692
  'inside the interactive browser, q detaches without stopping the underlying workflow; c requests the same cancellation with a confirmation prompt',
693
+ 'the default timeline is derived from durable state and events; press v for raw action-ledger and event evidence',
692
694
  ],
693
695
  examples: [
694
696
  { cmd: 'bullswarm workflow tui', note: 'interactive run picker' },
@@ -259,6 +259,8 @@ export function workflowPanelModel(row, {
259
259
  const isControlAction = (action) => orchestrator.autonomous
260
260
  && action.id === orchestrator.actionId
261
261
  && action.kind === 'decide';
262
+ const isPreflightAction = (action) => orchestrator.autonomous && action.id === 'scout';
263
+ const isNonPhaseAction = (action) => isControlAction(action) || isPreflightAction(action);
262
264
  const phaseNames = [];
263
265
  const addPhase = (name) => {
264
266
  if (name && !phaseNames.includes(name)) phaseNames.push(name);
@@ -266,12 +268,13 @@ export function workflowPanelModel(row, {
266
268
  for (const phase of state._doc?.phases ?? []) {
267
269
  const controlOnly = orchestrator.autonomous
268
270
  && (phase.steps ?? []).length
269
- && (phase.steps ?? []).every((step) => step.id === orchestrator.actionId && step.type === 'decide');
271
+ && (phase.steps ?? []).every((step) =>
272
+ (step.id === orchestrator.actionId && step.type === 'decide') || step.id === 'scout');
270
273
  if (!controlOnly) addPhase(phase.name);
271
274
  }
272
- for (const action of ledger) if (!isControlAction(action)) addPhase(action.phase);
273
- for (const step of state.steps ?? []) if (step.stepId !== orchestrator.actionId) addPhase(step.phase);
274
- if (state.currentStep?.id !== orchestrator.actionId) addPhase(state.currentStep?.phase);
275
+ for (const action of ledger) if (!isNonPhaseAction(action)) addPhase(action.phase);
276
+ for (const step of state.steps ?? []) if (step.stepId !== orchestrator.actionId && step.stepId !== 'scout') addPhase(step.phase);
277
+ if (state.currentStep?.id !== orchestrator.actionId && state.currentStep?.id !== 'scout') addPhase(state.currentStep?.phase);
275
278
  if (!orchestrator.autonomous) addPhase(state.currentPhase?.name);
276
279
  if (!phaseNames.length) phaseNames.push(orchestrator.autonomous ? 'execution' : 'starting');
277
280
 
@@ -283,7 +286,7 @@ export function workflowPanelModel(row, {
283
286
  phaseNames.length - 1,
284
287
  );
285
288
  const phases = phaseNames.map((name) => {
286
- const actions = ledger.filter((action) => action.phase === name && !isControlAction(action));
289
+ const actions = ledger.filter((action) => action.phase === name && !isNonPhaseAction(action));
287
290
  const effectiveStatuses = actions.map((action) => effectiveActionStatus(action, state));
288
291
  const completed = effectiveStatuses.filter((status) => TERMINAL_ACTIONS.has(status)).length;
289
292
  const failed = effectiveStatuses.filter((status) => String(status).startsWith('failed')).length;
@@ -322,6 +325,11 @@ export function workflowPanelModel(row, {
322
325
  }
323
326
  for (const [key, active] of Object.entries(state.activeAgents ?? {})) {
324
327
  if (representedActiveKeys.has(key)) continue;
328
+ // The autonomous orchestrator is a control-plane thread, not a worker in
329
+ // whichever execution phase happens to be selected. It has its own panel.
330
+ // Without this guard, an active checkpoint is re-added below a completed
331
+ // phase when both share the durable `autonomous-delivery` phase name.
332
+ if (orchestrator.autonomous && active.stepId === orchestrator.actionId) continue;
325
333
  const action = ledger.find((entry) => entry.id === active.stepId || active.stepId?.startsWith(`${entry.id}[`));
326
334
  if ((action?.phase ?? state.currentPhase?.name) !== selectedPhase.name) continue;
327
335
  agents.push({
@@ -355,10 +363,12 @@ export function renderWorkflowTui(row, {
355
363
  width = 120, height = 36, focus = 0, phaseIndex = null, agentIndex = null,
356
364
  detailScroll = 0, message = null, confirmCancel = false,
357
365
  controlSelected = false, orchestratorDetail = false, orchestratorVerbose = false,
366
+ workflowVerbose = false,
358
367
  spinnerFrame = 0,
359
368
  } = {}) {
360
- width = Math.max(38, Number(width) || 120);
369
+ width = Math.max(20, Number(width) || 120);
361
370
  height = Math.max(18, Number(height) || 36);
371
+ const narrow = width < 100;
362
372
  const model = workflowPanelModel(row, { phaseIndex, agentIndex });
363
373
  const state = model.state;
364
374
  const status = row?.status ?? state.status ?? 'starting';
@@ -372,17 +382,21 @@ export function renderWorkflowTui(row, {
372
382
  const terminalLabel = state.finishedAt ? ` · ${status === 'completed' ? 'done' : status}` : '';
373
383
  const runName = state.workflow ?? row?.shortId ?? state.shortId ?? row?.runId ?? 'workflow';
374
384
  const header = [
375
- ` ${truncate(runName, Math.max(1, width - agentProgress.length - elapsed.length - terminalLabel.length - 5))} · ${agentProgress}${elapsed}${terminalLabel}`,
385
+ truncate(` ${truncate(runName, Math.max(1, width - agentProgress.length - elapsed.length - terminalLabel.length - 5))} · ${agentProgress}${elapsed}${terminalLabel}`, width),
376
386
  ` ${truncate(state.intent?.goal ?? state.workflow ?? 'workflow', width - 2)}`,
377
387
  ];
378
388
  const footer = confirmCancel
379
389
  ? ' Stop this workflow? y confirm · n/Esc keep running'
380
390
  : orchestratorDetail
381
391
  ? ` ↑/↓ scroll · v ${orchestratorVerbose ? 'overview' : 'technical details'} · Esc back · c stop · q detach`
382
- : ` ↑/↓ select · Enter drill in · Esc back · o orchestrator · c stop · q detach`;
392
+ : workflowVerbose
393
+ ? ' ↑/↓ scroll · v overview · Esc back · c stop · q detach'
394
+ : narrow
395
+ ? ' ↑/↓ select · Enter inspect · Esc back · o planner · v technical · c stop · q detach'
396
+ : ' ↑/↓ select · PgUp/PgDn timeline · Enter inspect · ←/→ switch · v technical · q detach';
383
397
  const rawMessageLine = message
384
398
  ? ` ${truncate(message, width - 2)}`
385
- : ` ${orchestratorDetail ? `Orchestrator ${orchestratorVerbose ? 'technical details' : 'overview'}` : focus === 0 ? 'Phases' : focus === 1 ? 'Agents' : 'Agent activity'} · r refresh · workflow continues after detach`;
399
+ : ` ${orchestratorDetail ? `Workflow Planner ${orchestratorVerbose ? 'technical details' : 'overview'}` : workflowVerbose ? 'Workflow technical details' : focus === 0 ? (narrow ? 'Phases' : 'Timeline · auto-following newest event') : focus === 1 ? 'Agents' : 'Agent activity'} · r refresh · workflow continues after detach`;
386
400
  const messageLine = truncate(rawMessageLine, width);
387
401
  const bodyHeight = Math.max(10, height - header.length - 3);
388
402
 
@@ -414,7 +428,6 @@ export function renderWorkflowTui(row, {
414
428
 
415
429
  // Two information-rich panes become counterproductive on typical 80-column
416
430
  // SSH/mobile terminals. Keep the drill-down full-width below 100 columns.
417
- const narrow = width < 100;
418
431
  const leftWidth = narrow ? width : Math.max(24, Math.min(34, Math.floor(width * 0.27)));
419
432
  const rightWidth = narrow ? width : width - leftWidth;
420
433
  const orchestrationLines = orchestratorDetailLines(
@@ -426,8 +439,10 @@ export function renderWorkflowTui(row, {
426
439
  const detail = orchestratorDetail
427
440
  ? orchestrationLines
428
441
  : agentDetailLines(model, Math.max(20, rightWidth - 4), spinnerFrame);
442
+ const technical = workflowTechnicalLines(model, Math.max(20, rightWidth - 4));
429
443
  const contentHeight = bodyHeight - 2;
430
- const maxScroll = Math.max(0, detail.length - contentHeight);
444
+ const scrollSource = workflowVerbose ? technical : detail;
445
+ const maxScroll = Math.max(0, scrollSource.length - contentHeight);
431
446
  const scroll = clamp(detailScroll, 0, maxScroll);
432
447
  const visiblePhases = panelWindow(['', ...phaseLines], model.phaseIndex, 1, contentHeight).slice(1);
433
448
  const visibleAgents = panelWindow(['', ...agentLines], model.agentIndex, 1, contentHeight).slice(1);
@@ -436,7 +451,7 @@ export function renderWorkflowTui(row, {
436
451
  const orchestrationNavLines = model.orchestrator.autonomous
437
452
  ? [
438
453
  selectLine(
439
- `${statusIcon(model.orchestrator.active ? 'running' : model.orchestrator.status, spinnerFrame)} Orchestration · ${model.orchestrator.status}`,
454
+ `${statusIcon(model.orchestrator.active ? 'running' : model.orchestrator.status, spinnerFrame)} ${plannerDisplayStatus(model)}`,
440
455
  controlSelected,
441
456
  focus === 0 && !orchestratorDetail,
442
457
  leftWidth - 2,
@@ -445,6 +460,7 @@ export function renderWorkflowTui(row, {
445
460
  [model.orchestrator.pool, model.orchestrator.model].filter(Boolean).join(' · ') || 'select to inspect',
446
461
  leftWidth - 2,
447
462
  ),
463
+ dimLine(plannerUsageSummary(model), leftWidth - 2),
448
464
  ]
449
465
  : [];
450
466
  const narrowWorkflowLines = model.orchestrator.autonomous
@@ -457,7 +473,19 @@ export function renderWorkflowTui(row, {
457
473
 
458
474
  let body;
459
475
  if (orchestratorDetail) {
460
- body = renderPanel(`Orchestrator · ${orchestratorVerbose ? 'technical details' : 'overview'}`, visibleDetail, width, bodyHeight);
476
+ body = renderPanel(`Workflow Planner · ${orchestratorVerbose ? 'technical details' : 'overview'}`, visibleDetail, width, bodyHeight);
477
+ } else if (workflowVerbose) {
478
+ const visibleTechnical = technical.slice(scroll, scroll + contentHeight);
479
+ if (narrow) body = renderPanel('Workflow technical details', visibleTechnical, width, bodyHeight);
480
+ else {
481
+ const left = model.orchestrator.autonomous
482
+ ? [
483
+ ...renderPanel('Workflow Planner', orchestrationNavLines, leftWidth, 5),
484
+ ...renderPanel(phaseTitle, visiblePhases, leftWidth, bodyHeight - 5),
485
+ ]
486
+ : renderPanel(phaseTitle, visiblePhases, leftWidth, bodyHeight);
487
+ body = joinPanels(left, renderPanel('Workflow technical details', visibleTechnical, rightWidth, bodyHeight));
488
+ }
461
489
  } else if (narrow) {
462
490
  const mobile = focus === 0
463
491
  ? {
@@ -473,15 +501,17 @@ export function renderWorkflowTui(row, {
473
501
  } else if (focus < 2) {
474
502
  const left = model.orchestrator.autonomous
475
503
  ? [
476
- ...renderPanel('Orchestrator', orchestrationNavLines, leftWidth, 5),
504
+ ...renderPanel('Workflow Planner', orchestrationNavLines, leftWidth, 5),
477
505
  ...renderPanel(phaseTitle, visiblePhases, leftWidth, bodyHeight - 5),
478
506
  ]
479
507
  : renderPanel(phaseTitle, visiblePhases, leftWidth, bodyHeight);
480
508
  body = joinPanels(
481
509
  left,
482
510
  controlSelected
483
- ? renderPanel(`Orchestration · ${model.orchestrator.status}`, orchestrationLines.slice(0, contentHeight), rightWidth, bodyHeight)
484
- : renderPanel(agentTitle, visibleAgents, rightWidth, bodyHeight),
511
+ ? renderPanel(`Workflow Planner · ${model.orchestrator.status}`, orchestrationLines.slice(0, contentHeight), rightWidth, bodyHeight)
512
+ : focus === 0
513
+ ? renderWorkflowOverviewPanel(model, rightWidth, bodyHeight, spinnerFrame, detailScroll)
514
+ : renderPanel(agentTitle, visibleAgents, rightWidth, bodyHeight),
485
515
  );
486
516
  } else {
487
517
  body = joinPanels(
@@ -508,6 +538,349 @@ function joinPanels(left, right) {
508
538
  return left.map((line, index) => `${line}${right[index] ?? ''}`);
509
539
  }
510
540
 
541
+ function renderWorkflowOverviewPanel(model, width, height, spinnerFrame, timelineScroll = 0) {
542
+ const inner = Math.max(1, width - 2);
543
+ const timeline = workflowTimelineLines(model, inner);
544
+ const live = workflowLiveLines(model, inner, spinnerFrame);
545
+ const next = workflowNextLines(model, inner);
546
+ const contentRows = Math.max(3, height - 4); // outer border + two section dividers
547
+ const nextRows = Math.min(next.length, 2);
548
+ const liveRows = Math.min(live.lines.length, Math.max(2, Math.floor(contentRows * 0.42)));
549
+ const timelineRows = Math.max(1, contentRows - liveRows - nextRows);
550
+ const maxTimelineScroll = Math.max(0, timeline.lines.length - timelineRows);
551
+ const scroll = clamp(timelineScroll, 0, maxTimelineScroll);
552
+ const start = Math.max(0, timeline.lines.length - timelineRows - scroll);
553
+ let visibleTimeline = timeline.lines.slice(start, start + timelineRows);
554
+ if (start > 0 && visibleTimeline.length) {
555
+ visibleTimeline[0] = dimText(`↑ ${start + 1} earlier timeline rows`, inner);
556
+ }
557
+ if (start + timelineRows < timeline.lines.length && visibleTimeline.length) {
558
+ visibleTimeline[visibleTimeline.length - 1] = dimText(`↓ ${timeline.lines.length - start - timelineRows + 1} newer timeline rows`, inner);
559
+ }
560
+ const visibleLive = live.lines.slice(0, liveRows);
561
+ const visibleNext = next.slice(0, nextRows);
562
+ const title = ` Workflow timeline · ${timeline.milestoneCount} milestone${timeline.milestoneCount === 1 ? '' : 's'} `;
563
+ const rows = [`┌${truncate(title, inner)}${'─'.repeat(Math.max(0, inner - truncate(title, inner).length))}┐`];
564
+ for (const line of visibleTimeline) rows.push(`│${panelCell(line, inner)}│`);
565
+ while (rows.length < 1 + timelineRows) rows.push(`│${panelCell('', inner)}│`);
566
+ rows.push(sectionDivider(`Live · ${live.running} running · ${live.waiting} waiting`, inner));
567
+ for (const line of visibleLive) rows.push(`│${panelCell(line, inner)}│`);
568
+ while (rows.length < 2 + timelineRows + liveRows) rows.push(`│${panelCell('', inner)}│`);
569
+ rows.push(sectionDivider('Next', inner));
570
+ for (const line of visibleNext) rows.push(`│${panelCell(line, inner)}│`);
571
+ while (rows.length < height - 1) rows.push(`│${panelCell('', inner)}│`);
572
+ rows.push(`└${'─'.repeat(inner)}┘`);
573
+ return rows.slice(0, height);
574
+ }
575
+
576
+ function sectionDivider(label, inner) {
577
+ const text = truncate(` ${label} `, inner);
578
+ return `├${text}${'─'.repeat(Math.max(0, inner - text.length))}┤`;
579
+ }
580
+
581
+ function workflowTimelineLines(model, width) {
582
+ const { state, orchestrator } = model;
583
+ const ledger = state.actionLedger ?? [];
584
+ const events = [];
585
+ const add = (at, lines, sequence = Number.MAX_SAFE_INTEGER) => {
586
+ if (!at) return;
587
+ events.push({ at, sequence, lines: Array.isArray(lines) ? lines : [lines] });
588
+ };
589
+ const scout = ledger.find((action) => action.id === 'scout');
590
+ add(state.startedAt, [
591
+ timelineRow(state.startedAt, '● Workflow initiated', '', width),
592
+ timelineDetail(scout ? 'Goal accepted; preparing repository reconnaissance' : 'Execution started', width),
593
+ ]);
594
+
595
+ const scoutStartedAt = actionStartedAt(state, scout);
596
+ const scoutFinishedAt = actionFinishedAt(state, scout);
597
+ if (scoutStartedAt) {
598
+ add(scoutStartedAt, [
599
+ timelineRow(scoutStartedAt, '● [Preflight: Scout] started', '', width),
600
+ timelineDetail('Read-only repository and capability inspection', width),
601
+ ]);
602
+ }
603
+ if (scoutFinishedAt && TERMINAL_ACTIONS.has(effectiveActionStatus(scout, state))) {
604
+ const attempt = latestAttemptForAction(state, scout);
605
+ const metadata = [attempt?.pool, attempt?.model, tokenText(attempt?.usage)].filter(Boolean).join(' · ');
606
+ add(scoutFinishedAt, [
607
+ timelineRow(scoutFinishedAt, `${statusIcon(effectiveActionStatus(scout, state))} [Preflight: Scout] completed`, durationText(scoutStartedAt, scoutFinishedAt), width),
608
+ ...(metadata ? [timelineDetail(metadata, width)] : []),
609
+ ]);
610
+ }
611
+
612
+ orchestrator.attempts.forEach((attempt, index) => {
613
+ if (!attempt.finishedAt || !TERMINAL_ACTIONS.has(attempt.status)) return;
614
+ const decision = decisionForPlannerAttempt(state, attempt, index, orchestrator.attempts);
615
+ const summary = decision?.reason ? sentencePreview(decision.reason, Math.max(30, width - 10))
616
+ : decision ? decisionLabel(decision.decision) : 'No accepted decision; correction or retry turn';
617
+ add(attempt.finishedAt, [
618
+ timelineRow(attempt.finishedAt, `◆ [Workflow Planner] checkpoint #${index + 1}`, durationText(attempt.startedAt, attempt.finishedAt), width),
619
+ timelineDetail(summary, width),
620
+ ]);
621
+ });
622
+
623
+ const phases = new Map();
624
+ for (const action of ledger) {
625
+ if (action.id === 'scout' || (orchestrator.autonomous && action.id === orchestrator.actionId && action.kind === 'decide')) continue;
626
+ if (!action.phase) continue;
627
+ if (!phases.has(action.phase)) phases.set(action.phase, []);
628
+ phases.get(action.phase).push(action);
629
+ }
630
+ for (const [name, actions] of phases) {
631
+ const realStart = earliestTimestamp(actions.map((action) => actionStartedAt(state, action)));
632
+ const startedAt = realStart ?? earliestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
633
+ if (!startedAt) continue;
634
+ const label = phaseLabel(name, orchestrator);
635
+ add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width));
636
+ const finished = actions
637
+ .filter((action) => actionFinishedAt(state, action) && TERMINAL_ACTIONS.has(effectiveActionStatus(action, state)))
638
+ .sort((a, b) => Date.parse(actionFinishedAt(state, a)) - Date.parse(actionFinishedAt(state, b)));
639
+ finished.forEach((action, index) => {
640
+ const terminalPhase = finished.length === actions.length && index === finished.length - 1;
641
+ const branch = terminalPhase ? '│ └─' : '│ ├─';
642
+ const actionFinished = actionFinishedAt(state, action);
643
+ const actionStarted = actionStartedAt(state, action);
644
+ add(actionFinished, timelineRow(
645
+ actionFinished,
646
+ `${branch}${statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
647
+ actionStarted ? durationText(actionStarted, actionFinished) : '',
648
+ width,
649
+ ));
650
+ });
651
+ if (finished.length === actions.length && actions.length) {
652
+ const finishedAt = latestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
653
+ const failed = actions.some((action) => String(effectiveActionStatus(action, state)).startsWith('failed'));
654
+ add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : '✓'} [Phase: ${label}] completed`, `${finished.length}/${actions.length}`, width));
655
+ }
656
+ }
657
+
658
+ for (const event of model.events) {
659
+ const detail = timelineControlEvent(event, width);
660
+ if (detail) add(event.committedAt, detail, Number(event.sequence));
661
+ }
662
+
663
+ events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || a.sequence - b.sequence);
664
+ const lines = [];
665
+ events.forEach((event, index) => {
666
+ if (index) lines.push('');
667
+ lines.push(...event.lines);
668
+ });
669
+ return { lines: lines.length ? lines : ['Waiting for the first durable workflow milestone'], milestoneCount: events.length };
670
+ }
671
+
672
+ function decisionForPlannerAttempt(state, attempt, index, attempts) {
673
+ const decisions = state.decisions ?? [];
674
+ if (attempt?.outFile) {
675
+ const artifactMatch = decisions.find((decision) => decision.artifact === attempt.outFile);
676
+ if (artifactMatch) return artifactMatch;
677
+ }
678
+ const started = Date.parse(attempt?.startedAt ?? '');
679
+ const finished = Date.parse(attempt?.finishedAt ?? '');
680
+ if (Number.isFinite(started) && Number.isFinite(finished)) {
681
+ const timeMatch = decisions.find((decision) => {
682
+ const created = Date.parse(decision.createdAt ?? '');
683
+ return Number.isFinite(created) && created >= started && created <= finished + 2_000;
684
+ });
685
+ if (timeMatch) return timeMatch;
686
+ }
687
+ const hasDurableCorrelation = decisions.some((decision) => decision.artifact || decision.createdAt)
688
+ || attempts.some((entry) => entry.outFile);
689
+ return hasDurableCorrelation ? null : decisions[index];
690
+ }
691
+
692
+ function timelineControlEvent(event, width) {
693
+ const labels = {
694
+ 'decision.rejected': '✗ [Workflow Planner] decision rejected',
695
+ 'decision.correction_requested': '⧖ [Workflow Planner] correction requested',
696
+ 'decision.orchestrator_escalated': '◆ [Workflow Planner] provider escalated',
697
+ 'run.cancellation_requested': '⧖ Workflow cancellation requested',
698
+ 'run.cancelling': '⧖ Workflow cancellation requested',
699
+ 'run.interruption_requested': '⧖ Workflow interruption requested',
700
+ 'workflow.expansion_target_exceeded': '! Advisory expansion target exceeded',
701
+ 'workflow.agent_target_exceeded': '! Advisory agent target exceeded',
702
+ };
703
+ const label = labels[event.type];
704
+ if (!label) return null;
705
+ const reason = event.payload?.why ?? event.payload?.reason;
706
+ return [
707
+ timelineRow(event.committedAt, label, '', width),
708
+ ...(reason ? [timelineDetail(sentencePreview(reason, Math.max(20, width - 8)), width)] : []),
709
+ ];
710
+ }
711
+
712
+ function workflowLiveLines(model, width, spinnerFrame) {
713
+ const { state, orchestrator } = model;
714
+ const activeWorkers = Object.values(state.activeAgents ?? {})
715
+ .filter((agent) => agent.stepId !== orchestrator.actionId)
716
+ .sort((a, b) => String(b.lastEventAt ?? b.lastActivityAt ?? '').localeCompare(String(a.lastEventAt ?? a.lastActivityAt ?? '')));
717
+ const lines = [];
718
+ let running = activeWorkers.length + (orchestrator.active ? 1 : 0);
719
+ let waiting = 0;
720
+ if (orchestrator.autonomous && !state.finishedAt) {
721
+ const plannerWaiting = !orchestrator.active && activeWorkers.length > 0;
722
+ if (plannerWaiting) waiting += 1;
723
+ const plannerStatus = orchestrator.active ? 'planning' : plannerWaiting ? 'waiting' : orchestrator.status;
724
+ lines.push(alignRight(
725
+ `${statusIcon(plannerStatus, spinnerFrame)} [Workflow Planner] · ${orchestrator.pool} · ${orchestrator.model}`,
726
+ plannerStatus,
727
+ width,
728
+ ));
729
+ if (plannerWaiting) lines.push(` Waiting for ${activeWorkers.length === 1 ? activeWorkers[0].stepId : `${activeWorkers.length} workers`}`);
730
+ else if (orchestrator.active) lines.push(' Choosing the next smallest useful action');
731
+ else lines.push(` ${humanStatus(orchestrator.status)}`);
732
+ const plannerAction = orchestrator.active?.lastActions?.at(-1) ?? orchestrator.latestAttempt?.lastActions?.at(-1);
733
+ if (plannerAction) lines.push(` ↳ ${friendlyActionKind(plannerAction.kind)}${plannerAction.summary ? ` · ${friendlyActionSummary(plannerAction)}` : ''}`);
734
+ else if (orchestrator.latestDecision) lines.push(` ↳ Decision · ${decisionLabel(orchestrator.latestDecision.decision)}`);
735
+ const plannerStream = streamActivityLine(orchestrator.active);
736
+ if (plannerStream) lines.push(` ${plannerStream}`);
737
+ lines.push('');
738
+ }
739
+ for (const agent of activeWorkers) {
740
+ const action = (state.actionLedger ?? []).find((entry) => entry.id === agent.stepId || agent.stepId?.startsWith(`${entry.id}[`));
741
+ lines.push(alignRight(
742
+ `${statusIcon(agent.status ?? 'running', spinnerFrame)} ${agent.stepId} · ${agent.pool ?? 'unassigned'} · ${agent.model ?? 'connector model'}`,
743
+ durationText(action?.startedAt ?? agent.startedAt),
744
+ width,
745
+ ));
746
+ const latest = agent.lastActions?.at(-1);
747
+ lines.push(latest
748
+ ? ` ↳ ${friendlyActionKind(latest.kind)}${latest.summary ? ` · ${friendlyActionSummary(latest)}` : ''}`
749
+ : ' ↳ waiting for the first semantic action event');
750
+ const stream = streamActivityLine(agent);
751
+ if (stream) lines.push(` ${stream}`);
752
+ lines.push('');
753
+ }
754
+ if (!lines.length) lines.push(state.finishedAt ? '✓ No live agents · workflow is terminal' : '⧖ Waiting for the next dispatch');
755
+ return { lines, running, waiting };
756
+ }
757
+
758
+ function workflowNextLines(model, width) {
759
+ const { state, orchestrator } = model;
760
+ if (state.finishedAt) return [truncate(`${statusIcon(state.status)} Workflow terminal · obtain the stable result envelope`, width)];
761
+ const ledger = state.actionLedger ?? [];
762
+ const pending = ledger.find((action) => action.id !== 'scout'
763
+ && action.id !== orchestrator.actionId
764
+ && !TERMINAL_ACTIONS.has(effectiveActionStatus(action, state))
765
+ && effectiveActionStatus(action, state) !== 'running');
766
+ if (pending) {
767
+ const blockers = (pending.dependsOn ?? []).filter((id) => state.outputs?.[id]?.ok !== true);
768
+ const wait = blockers.length ? ` · waiting for ${blockers.join(', ')}` : '';
769
+ return [truncate(`○ [Phase: ${phaseLabel(pending.phase, orchestrator)}] · ${pending.id}${wait}`, width)];
770
+ }
771
+ if (Object.keys(state.activeAgents ?? {}).some((key) => state.activeAgents[key]?.stepId !== orchestrator.actionId)) {
772
+ return ['○ [Workflow Planner] will reassess when current work finishes'];
773
+ }
774
+ if (orchestrator.active) return ['○ Awaiting the next [Workflow Planner] decision'];
775
+ return ['○ Awaiting the next [Workflow Planner] decision'];
776
+ }
777
+
778
+ function workflowTechnicalLines(model, width) {
779
+ const { state, orchestrator } = model;
780
+ const lines = [
781
+ `Status · ${state.status ?? 'starting'}`,
782
+ `Current · ${state.currentStep?.id ?? '—'} · ${state.currentStep?.phase ?? state.currentPhase?.name ?? '—'}`,
783
+ `Started · ${state.startedAt ?? '—'}`,
784
+ `Usage · ${compactUsage(state.usage)}`,
785
+ '',
786
+ 'Action ledger',
787
+ ];
788
+ for (const action of state.actionLedger ?? []) {
789
+ const control = orchestrator.autonomous && action.id === orchestrator.actionId && action.kind === 'decide';
790
+ lines.push(`${statusIcon(effectiveActionStatus(action, state))} ${control ? '[Workflow Planner]' : action.id} · ${action.kind} · ${action.status ?? 'pending'} · ${action.phase ?? '—'}`);
791
+ }
792
+ if (!(state.actionLedger ?? []).length) lines.push('· no actions recorded');
793
+ lines.push('', 'Recent durable events');
794
+ for (const event of model.events.slice(-12)) lines.push(`#${event.sequence} ${event.type}`);
795
+ if (!model.events.length) lines.push('· no events recorded');
796
+ return wrapLines(lines, width);
797
+ }
798
+
799
+ function timelineRow(at, label, right, width) {
800
+ return alignRight(`${clockText(at)} ${label}`, right, width);
801
+ }
802
+
803
+ function timelineDetail(text, width) {
804
+ return truncate(` ${text}`, width);
805
+ }
806
+
807
+ function alignRight(left, right, width) {
808
+ const suffix = right ? String(right) : '';
809
+ if (!suffix) return truncate(left, width);
810
+ const room = Math.max(1, width - suffix.length - 1);
811
+ const lhs = truncate(left, room);
812
+ return `${lhs}${' '.repeat(Math.max(1, width - lhs.length - suffix.length))}${suffix}`;
813
+ }
814
+
815
+ function clockText(value) {
816
+ const date = new Date(value ?? '');
817
+ if (!Number.isFinite(date.getTime())) return '--:--';
818
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
819
+ }
820
+
821
+ function earliestTimestamp(values) {
822
+ return values.filter(Boolean).sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? null;
823
+ }
824
+
825
+ function latestTimestamp(values) {
826
+ return values.filter(Boolean).sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
827
+ }
828
+
829
+ function latestAttemptForAction(state, action) {
830
+ if (!action) return null;
831
+ return (action.attempts ?? []).map((index) => state.attempts?.[index]).filter(Boolean).at(-1) ?? null;
832
+ }
833
+
834
+ function actionStartedAt(state, action) {
835
+ if (!action) return null;
836
+ const attempts = (action.attempts ?? []).map((index) => state.attempts?.[index]).filter(Boolean);
837
+ return action.startedAt ?? earliestTimestamp(attempts.map((attempt) => attempt.startedAt));
838
+ }
839
+
840
+ function actionFinishedAt(state, action) {
841
+ if (!action) return null;
842
+ const attempts = (action.attempts ?? []).map((index) => state.attempts?.[index]).filter(Boolean);
843
+ return action.finishedAt ?? latestTimestamp(attempts.map((attempt) => attempt.finishedAt));
844
+ }
845
+
846
+ function streamActivityLine(agent) {
847
+ if (!agent) return '';
848
+ const at = agent.lastEventAt ?? agent.lastActivityAt;
849
+ if (!at) return 'stream waiting for the first provider event';
850
+ return `stream active ${durationText(at)} ago · ${formatBytes(agent.outputBytesObserved ?? 0)} observed`;
851
+ }
852
+
853
+ function formatBytes(value) {
854
+ const bytes = Math.max(0, Number(value) || 0);
855
+ if (bytes < 1024) return `${bytes} B`;
856
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
857
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
858
+ }
859
+
860
+ function humanStatus(value) {
861
+ return String(value ?? 'waiting').replaceAll('_', ' ').replace(/^./, (char) => char.toUpperCase());
862
+ }
863
+
864
+ function plannerDisplayStatus(model) {
865
+ const { orchestrator, state } = model;
866
+ if (state.finishedAt) return state.status === 'completed' ? 'Completed' : humanStatus(state.status);
867
+ if (orchestrator.active) return 'Planning next actions';
868
+ const workers = Object.values(state.activeAgents ?? {}).filter((agent) => agent.stepId !== orchestrator.actionId);
869
+ if (workers.length) return 'Waiting for workers';
870
+ if (orchestrator.status === 'reviewing evidence') return 'Reviewing evidence';
871
+ return humanStatus(orchestrator.status);
872
+ }
873
+
874
+ function plannerUsageSummary(model) {
875
+ const checkpoints = model.orchestrator.attempts.length;
876
+ const cost = model.state.usage?.cost?.estimatedUsd ?? model.state.usage?.cost?.knownSubtotalUsd;
877
+ return `Checkpoints ${checkpoints}${Number.isFinite(cost) ? ` · $${cost.toFixed(2)}` : ''}`;
878
+ }
879
+
880
+ function dimText(value, width) {
881
+ return `\x1b[2m${truncate(value, width)}\x1b[0m`;
882
+ }
883
+
511
884
  function orchestratorDetailLines(model, width, spinnerFrame, { verbose = false } = {}) {
512
885
  const { orchestrator, state } = model;
513
886
  if (!orchestrator.autonomous) {
@@ -520,6 +893,7 @@ function orchestratorDetailLines(model, width, spinnerFrame, { verbose = false }
520
893
  const conversations = Object.entries(state.orchestration?.conversations ?? {});
521
894
  const decisions = state.decisions ?? [];
522
895
  const latestDecision = decisions.at(-1);
896
+ const latestLiveAction = liveActions.at(-1) ?? null;
523
897
  const workerAttempts = (state.attempts ?? []).filter((attempt) => attempt.actionId !== orchestrator.actionId);
524
898
  const completedWorkers = workerAttempts.filter((attempt) => TERMINAL_ACTIONS.has(attempt.status)).length;
525
899
  const activeWorkers = Object.values(state.activeAgents ?? {})
@@ -538,6 +912,14 @@ function orchestratorDetailLines(model, width, spinnerFrame, { verbose = false }
538
912
  `Now · ${stateLabel}`,
539
913
  `Progress · ${completedWorkers}/${workerAttempts.length} worker attempts finished · ${orchestrator.attempts.length} planning checkpoint${orchestrator.attempts.length === 1 ? '' : 's'}`,
540
914
  ];
915
+ if (active) {
916
+ lines.push(latestLiveAction
917
+ ? `Latest action · ${friendlyActionKind(latestLiveAction.kind)}${latestLiveAction.summary ? ` · ${friendlyActionSummary(latestLiveAction)}` : ''}`
918
+ : 'Latest action · waiting for the first semantic event');
919
+ lines.push(active.lastEventAt
920
+ ? `Live stream · event ${durationText(active.lastEventAt)} ago · ${active.outputBytesObserved ?? 0} bytes observed`
921
+ : 'Live stream · waiting for the first provider event');
922
+ }
541
923
  if (latestDecision) {
542
924
  lines.push(`Latest decision · ${decisionLabel(latestDecision.decision)}`);
543
925
  if (latestDecision.reason) lines.push(`Why · ${sentencePreview(latestDecision.reason)}`);
@@ -581,7 +963,7 @@ function orchestratorDetailLines(model, width, spinnerFrame, { verbose = false }
581
963
  lines.push('', 'Checkpoint turns');
582
964
  if (!orchestrator.attempts.length) lines.push('· waiting for the first planning turn');
583
965
  orchestrator.attempts.forEach((attempt, index) => {
584
- const decision = (state.decisions ?? [])[index];
966
+ const decision = decisionForPlannerAttempt(state, attempt, index, orchestrator.attempts);
585
967
  lines.push(`#${index + 1} ${statusIcon(attempt.status, spinnerFrame)} ${attempt.status} · ${attempt.pool ?? '—'} · ${attempt.model ?? 'connector model'} · ${durationText(attempt.startedAt, attempt.finishedAt)}`);
586
968
  lines.push(` ${compactUsage(attempt.usage)}`);
587
969
  if (decision) lines.push(` decision: ${decision.decision} · ${decision.reason}`);
@@ -845,6 +1227,7 @@ export async function runDashboard(bullswarmDir, {
845
1227
  controlSelected: false,
846
1228
  orchestratorDetail: false,
847
1229
  orchestratorVerbose: false,
1230
+ workflowVerbose: false,
848
1231
  spinnerFrame: 0,
849
1232
  };
850
1233
  const paintUnsafe = () => {
@@ -915,7 +1298,7 @@ export async function runDashboard(bullswarmDir, {
915
1298
  }
916
1299
  const row = detailRow(bullswarmDir, selectedRunId);
917
1300
  const model = workflowPanelModel(row, { phaseIndex: ui.phaseIndex, agentIndex: ui.agentIndex });
918
- if (ui.orchestratorDetail) {
1301
+ if (ui.orchestratorDetail || ui.workflowVerbose) {
919
1302
  ui.detailScroll = Math.max(0, ui.detailScroll + delta);
920
1303
  return paint();
921
1304
  }
@@ -979,27 +1362,32 @@ export async function runDashboard(bullswarmDir, {
979
1362
  ui.focus = 0;
980
1363
  ui.controlSelected = true;
981
1364
  ui.detailScroll = 0;
1365
+ } else if (ui.workflowVerbose) {
1366
+ ui.workflowVerbose = false;
1367
+ ui.detailScroll = 0;
982
1368
  } else if (detail && ui.focus > 0) ui.focus -= 1;
983
1369
  else if (detail && !token) detail = false;
984
1370
  else message = 'At phase level · press q to detach while the workflow keeps running.';
985
1371
  return paint();
986
1372
  }
987
1373
  if (key === '\t' || key === '\u001b[C' || key === 'l') {
988
- if (detail) ui.focus = (ui.focus + 1) % 3;
1374
+ if (detail) { ui.focus = (ui.focus + 1) % 3; ui.detailScroll = 0; }
989
1375
  return paint();
990
1376
  }
991
1377
  if (key === '\u001b[D' || key === 'h') {
992
- if (detail) ui.focus = (ui.focus + 2) % 3;
1378
+ if (detail) { ui.focus = (ui.focus + 2) % 3; ui.detailScroll = 0; }
993
1379
  return paint();
994
1380
  }
995
1381
  if (key === '\u001b[A' || key === 'k') return moveVertical(-1);
996
1382
  if (key === '\u001b[B' || key === 'j') return moveVertical(1);
997
- if (key === '\u001b[5~') { ui.detailScroll = Math.max(0, ui.detailScroll - 8); return paint(); }
998
- if (key === '\u001b[6~') { ui.detailScroll += 8; return paint(); }
1383
+ const timelineScroll = detail && ui.focus === 0 && !ui.orchestratorDetail && !ui.workflowVerbose;
1384
+ if (key === '\u001b[5~') { ui.detailScroll = timelineScroll ? ui.detailScroll + 8 : Math.max(0, ui.detailScroll - 8); return paint(); }
1385
+ if (key === '\u001b[6~') { ui.detailScroll = timelineScroll ? Math.max(0, ui.detailScroll - 8) : ui.detailScroll + 8; return paint(); }
999
1386
  if (key === 'o' && detail) {
1000
1387
  const row = detailRow(bullswarmDir, selectedRunId);
1001
1388
  const model = workflowPanelModel(row, { phaseIndex: ui.phaseIndex, agentIndex: ui.agentIndex });
1002
1389
  if (model.orchestrator.autonomous) {
1390
+ ui.workflowVerbose = false;
1003
1391
  ui.orchestratorDetail = true;
1004
1392
  ui.orchestratorVerbose = false;
1005
1393
  ui.controlSelected = true;
@@ -1011,8 +1399,9 @@ export async function runDashboard(bullswarmDir, {
1011
1399
  if (key === '1' && detail) { ui.focus = 0; return paint(); }
1012
1400
  if (key === '2' && detail) { ui.focus = 1; return paint(); }
1013
1401
  if (key === '3' && detail) { ui.focus = 2; return paint(); }
1014
- if (key === 'v' && ui.orchestratorDetail) {
1015
- ui.orchestratorVerbose = !ui.orchestratorVerbose;
1402
+ if (key === 'v' && detail) {
1403
+ if (ui.orchestratorDetail) ui.orchestratorVerbose = !ui.orchestratorVerbose;
1404
+ else ui.workflowVerbose = !ui.workflowVerbose;
1016
1405
  ui.detailScroll = 0;
1017
1406
  message = null;
1018
1407
  return paint();
@@ -1025,6 +1414,7 @@ export async function runDashboard(bullswarmDir, {
1025
1414
  ui.followActiveAgent = true;
1026
1415
  } else if (ui.focus === 0) {
1027
1416
  if (ui.controlSelected) {
1417
+ ui.workflowVerbose = false;
1028
1418
  ui.orchestratorDetail = true;
1029
1419
  ui.orchestratorVerbose = false;
1030
1420
  ui.detailScroll = 0;
@@ -9,15 +9,15 @@ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
9
9
 
10
10
  export const PLANNER_RULES_SECTION = [
11
11
  '1. Compile the whole program in one decision: the runtime runs every proposed action and consults you only at a finished-or-blocked boundary, so deferred work costs a round trip.',
12
- '2. Make every worker prompt self-contained: include the exact goal, absolute cwd, the owned files you assign (never an and/or choice, which blocks a parallel sibling) and a no-other-files boundary, expected artifact, acceptance command and report format: workers see only their own prompt.',
13
- '3. A phase is a pipeline stage: one kebab-case name shared by its actions (implement, verify, report), never one per action; phases are forward-only, so recovery opens a new one and never repeats an identical failed plan. Wall-clock is the longest dependsOn chain, so depend only on real data or same-file ordering: a worker depends on the run that wrote its input files, never on that run\'s verify (a verdict is not data), so it starts as that verify runs.',
14
- '4. Split to the width the tree allows: each file-disjoint unit (module, test file, doc) is its own concurrent run plus its own verify depending only on that run, then one suite verify depending on all; one worker for N independent files is N chains in series. A verify judges the artifact in review (default: its single or last dependency; with no dependsOn it audits the repository).',
12
+ '2. Make every worker prompt self-contained: include the exact goal, absolute cwd, the owned files you assign (never an and/or choice, which blocks a sibling) and a no-other-files boundary, expected artifact, acceptance command and report format: workers see only their own prompt.',
13
+ '3. A phase is a pipeline stage: one kebab-case name shared by its actions (implement, verify), never one per action; phases are forward-only, so recovery opens a new one and never repeats an identical failed plan. Wall-clock is the longest dependsOn chain, so depend only on real data or same-file ordering: a worker depends on the run that wrote its input files, never on that run\'s verify (a verdict is not data), so it starts as that verify runs.',
14
+ '4. Split to the width the tree allows: each file-disjoint unit (module, test file, doc) is its own concurrent run plus its own verify depending only on that run, then one suite verify depending on all; one worker for N independent files is N chains in series. A verify judges the artifact in review (default: its last dependency; none: the repository).',
15
15
  '5. For unknown items, create discovery ending with RETURN ONLY a JSON object containing an items array, then data-driven fan-out via itemsFrom outputs.<id>.outFile or outputs.<id>.data.<field>; the runtime extracts the list, retrying once read-only if needed.',
16
- '6. Put outputSchema only on a worker whose object a LATER action reads via itemsFrom or outputs.<id>.data.<field>, and tell it to RETURN ONLY the object; a prose report or any answer containing fenced JSON gets no schema: the runtime parses the last {...} of the text, so a schema on prose costs a retry and a planner turn.',
17
- '7. Put verify.repair on every verify and scope each verify to what can be true at its point in the graph: later-scheduled work is not a defect and cosmetic mismatches are concerns, not ok:false. ok:false is repaired and re-checked inside the program; the repair edits files and cannot rewrite the answer under review, so reject only what a file edit can fix and report a wrong claim as a concern with the true value; ok:true is accepted and its concerns are informational.',
18
- '8. Add completion with all-actions-ok whenever a clean program finishes the goal; when its acceptance checks pass, return complete rather than adding polish actions. The program\'s LAST worker must be covered by a successful verify — never end on an unverified report or run. Return complete only on durable verified evidence, never proceed, never ask the user, and stop only for a concrete unresolved blocker with a qualified outcome.',
19
- '9. Budgets (agents, duration, expansion rounds) are advisory targets, never hard stops; the dispatch budget counts this planner call plus workers, verifiers, retries and escalations. Converge as targets approach: skip optional work; exceed a target only for one essential action or a required verification.',
20
- '10. Never propose pool, addDir, taskFile, shell authority, or unbounded work: routing is the runtime\'s. Set lane (analyze to read or judge, build to edit, chore for mechanical steps) and effort (low for checks and mechanical edits, high only where judgement decides) per action or repair; they pick the model tier (unset: build, medium).',
16
+ '6. Put outputSchema only on a worker whose object a LATER action reads via itemsFrom or outputs.<id>.data.<field>, and tell it to RETURN ONLY the object; a prose report or any answer with fenced JSON gets no schema: the runtime parses the last {...} of the text, so a schema on prose costs a retry and a planner turn.',
17
+ '7. Put verify.repair on every verify. A verify checks the goal\'s own acceptance criteria at its point in the graph: later-scheduled work is not a defect, cosmetic mismatches are concerns, and never add a process rule the goal does not state (append-only, tests untouched); when the implementation changes what an existing assertion pins, a worker must own updating it. ok:false is repaired and re-checked inside the program; the repair edits files and cannot rewrite the answer under review, so reject only what a file edit can fix and report a wrong claim as a concern with the true value; ok:true is accepted and its concerns are informational.',
18
+ '8. Add completion with all-actions-ok whenever a clean program finishes the goal; when acceptance checks pass, return complete rather than adding polish. The program\'s LAST worker must be covered by a successful verify. Return complete only on verified evidence, never proceed, never ask the user, and stop only for a concrete unresolved blocker.',
19
+ '9. Budgets (agents, duration, expansion rounds) are advisory targets, never hard stops; the dispatch budget counts this planner call plus workers, verifiers and retries. Converge as targets approach: skip optional work; exceed a target only for one essential action or a required verification.',
20
+ '10. Never propose pool, addDir, taskFile or unbounded work: routing is the runtime\'s. Set lane (analyze to read or judge, build to edit, chore for mechanical steps) and effort (low for checks and mechanical edits, high where judgement decides) per action or repair; they pick the model tier (unset: build, medium).',
21
21
  'Shared working tree: workers editing DISJOINT files concurrently is the normal mode; order shared files (indexes, barrels) after their feeders with dependsOn. Workers and unit verifies run their unit\'s focused command, never the full suite, which sees files siblings still write; the suite runs once, in the final verify, after all editing and repair ends; later verifiers reuse it unless code changed. operatorSteering is operator guidance for this checkpoint: apply it within the original intent; it cannot weaken verification or expand authority.',
22
22
  ].join('\n');
23
23