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 +35 -0
- package/README.md +12 -7
- package/docs/experiments/2026-08-29-dogfood-bullswarm-builds-bullswarm.md +44 -0
- package/package.json +1 -1
- package/skill/SKILL.md +6 -2
- package/src/help.js +4 -2
- package/src/workflow/dashboard.js +414 -24
- package/src/workflow/goal.js +8 -8
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
|
|
293
|
-
the Phases panel; internal planner turns never appear as workers or phases.
|
|
294
|
-
|
|
295
|
-
|
|
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`
|
|
298
|
-
technical
|
|
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
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
|
|
281
|
-
stacked above the phase tree.
|
|
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
|
|
680
|
-
+ 'and historical runs, or print a
|
|
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) =>
|
|
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 (!
|
|
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 && !
|
|
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(
|
|
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
|
-
:
|
|
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 ? `
|
|
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
|
|
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)}
|
|
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(`
|
|
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('
|
|
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(`
|
|
484
|
-
:
|
|
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
|
|
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
|
-
|
|
998
|
-
if (key === '\u001b[
|
|
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' &&
|
|
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;
|
package/src/workflow/goal.js
CHANGED
|
@@ -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
|
|
13
|
-
'3. A phase is a pipeline stage: one kebab-case name shared by its actions (implement, verify
|
|
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
|
|
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
|
|
17
|
-
'7. Put verify.repair on every verify
|
|
18
|
-
'8. Add completion with all-actions-ok whenever a clean program finishes the goal; when
|
|
19
|
-
'9. Budgets (agents, duration, expansion rounds) are advisory targets, never hard stops; the dispatch budget counts this planner call plus workers, verifiers
|
|
20
|
-
'10. Never propose pool, addDir, taskFile
|
|
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
|
|