bullswarm 0.18.0 → 0.18.2

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,34 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.18.2 — truthful recovery timeline and verifier cleanup
4
+
5
+ - Dependency-blocked plan branches now appear as a single skipped phase in the
6
+ workflow timeline instead of several failed agents followed by a
7
+ contradictory completed phase.
8
+ - The Live panel ignores stale terminal agent records, and verifier retry
9
+ cleanup now removes the retrying agent as soon as the bounded retry ends.
10
+ - Verifier verdict parsing conservatively repairs an otherwise valid JSON
11
+ object truncated only by missing final closing brackets. Malformed content
12
+ still fails closed, while provider truncation no longer forces an expensive
13
+ planner recovery round.
14
+
15
+ ## 0.18.1 — original-goal verification and denser timeline
16
+
17
+ - Goal workflows now derive a durable requirement ledger from the original
18
+ user goal. Planner verify actions declare which requirements they cover, and
19
+ neither explicit nor program-level completion is accepted until every
20
+ requirement has a successful verifier with specific evidence. The verifier
21
+ receives the original goal from the runtime, so a reduced planner scope can
22
+ no longer silently omit requested APIs, events, tests, or documentation.
23
+ - Successful verifier concerns are preserved in a verified
24
+ `completed_with_concerns` result instead of being discarded or triggering
25
+ unnecessary follow-up spending.
26
+ - The human timeline calls its first accepted planner decision `plan created`,
27
+ later decisions `plan updated`, and the final one `completion confirmed`.
28
+ Execution milestones are rendered as one dense block without blank rows.
29
+ Finished workflows now say `No agents running · workflow finished` and
30
+ `Workflow finished · result ready` instead of control-plane terminology.
31
+
3
32
  ## 0.18.0 — exact routes, cheaper plans, clearer results
4
33
 
5
34
  - `workflow goal` can now guarantee an exact planner model and a separate exact
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
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": {
@@ -201,15 +201,19 @@ const TERMINAL_ACTIONS = new Set([
201
201
  ]);
202
202
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
203
203
 
204
+ function isLiveAgent(agent) {
205
+ return !agent?.status || agent.status === 'running';
206
+ }
207
+
204
208
  function autonomousControlPlane(state) {
205
209
  const autonomous = state.intent?.autonomous === true || state.orchestration?.mode === 'autonomous';
206
210
  if (!autonomous) return { autonomous: false, actionId: null, attempts: [], active: null };
207
211
  const actionId = state.decisions?.find((decision) => decision.gateId)?.gateId ?? 'orchestrator';
208
212
  const attempts = (state.attempts ?? []).filter((attempt) => attempt.actionId === actionId);
209
- const active = Object.values(state.activeAgents ?? {}).find((agent) => agent.stepId === actionId) ?? null;
213
+ const active = Object.values(state.activeAgents ?? {}).find((agent) => agent.stepId === actionId && isLiveAgent(agent)) ?? null;
210
214
  const latestAttempt = attempts.at(-1) ?? null;
211
215
  const terminal = Boolean(state.finishedAt);
212
- const workerActive = Object.values(state.activeAgents ?? {}).some((agent) => agent.stepId !== actionId);
216
+ const workerActive = Object.values(state.activeAgents ?? {}).some((agent) => agent.stepId !== actionId && isLiveAgent(agent));
213
217
  const status = terminal
214
218
  ? state.status === 'completed' ? 'completed' : state.status ?? 'finished'
215
219
  : active ? 'planning'
@@ -233,7 +237,7 @@ function effectiveActionStatus(action, state) {
233
237
  // running even when a previous round recorded ok:false — a failed mark on
234
238
  // work that is still being retried misreports the run (user report 2026-08-29).
235
239
  const active = Object.values(state.activeAgents ?? {}).some((agent) =>
236
- agent.stepId === action.id || String(agent.stepId ?? '').startsWith(`${action.id}[`));
240
+ isLiveAgent(agent) && (agent.stepId === action.id || String(agent.stepId ?? '').startsWith(`${action.id}[`)));
237
241
  if (active || action.status === 'running') return 'running';
238
242
  const output = state.outputs?.[action.id];
239
243
  if (output?.ok === false) return 'failed_terminal';
@@ -588,9 +592,9 @@ function workflowTimelineLines(model, width) {
588
592
  const { state, orchestrator } = model;
589
593
  const ledger = state.actionLedger ?? [];
590
594
  const events = [];
591
- const add = (at, lines, sequence = Number.MAX_SAFE_INTEGER) => {
595
+ const add = (at, lines, sequence = Number.MAX_SAFE_INTEGER, group = null) => {
592
596
  if (!at) return;
593
- events.push({ at, sequence, lines: Array.isArray(lines) ? lines : [lines] });
597
+ events.push({ at, sequence, group, lines: Array.isArray(lines) ? lines : [lines] });
594
598
  };
595
599
  const scout = ledger.find((action) => action.id === 'scout');
596
600
  add(state.startedAt, [
@@ -620,10 +624,17 @@ function workflowTimelineLines(model, width) {
620
624
  const decision = decisionForPlannerAttempt(state, attempt, index, orchestrator.attempts);
621
625
  const summary = decision?.reason ? sentencePreview(decision.reason, Math.max(30, width - 10))
622
626
  : decision ? decisionLabel(decision.decision) : 'No accepted decision; correction or retry turn';
627
+ const acceptedBefore = orchestrator.attempts.slice(0, index).filter((entry, priorIndex) =>
628
+ decisionForPlannerAttempt(state, entry, priorIndex, orchestrator.attempts)).length;
629
+ const plannerLabel = !decision
630
+ ? `planning retry #${index + 1}`
631
+ : decision.decision === 'complete'
632
+ ? 'completion confirmed'
633
+ : acceptedBefore === 0 ? 'plan created' : `plan updated #${acceptedBefore + 1}`;
623
634
  add(attempt.finishedAt, [
624
- timelineRow(attempt.finishedAt, `◆ [Workflow Planner] checkpoint #${index + 1}`, durationText(attempt.startedAt, attempt.finishedAt), width),
635
+ timelineRow(attempt.finishedAt, `◆ [Workflow Planner] ${plannerLabel}`, durationText(attempt.startedAt, attempt.finishedAt), width),
625
636
  timelineDetail(summary, width),
626
- ]);
637
+ ], Number.MAX_SAFE_INTEGER, 'execution');
627
638
  });
628
639
 
629
640
  const phases = new Map();
@@ -638,7 +649,18 @@ function workflowTimelineLines(model, width) {
638
649
  const startedAt = realStart ?? earliestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
639
650
  if (!startedAt) continue;
640
651
  const label = phaseLabel(name, orchestrator);
641
- add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width));
652
+ const dependencyBlocked = actions.filter((action) => state.outputs?.[action.id]?.dependencyBlocked === true);
653
+ if (!realStart && dependencyBlocked.length === actions.length) {
654
+ const finishedAt = latestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
655
+ if (finishedAt) {
656
+ add(finishedAt, [
657
+ timelineRow(finishedAt, `⊘ [Phase: ${label}] skipped`, `${actions.length} action${actions.length === 1 ? '' : 's'} not run`, width),
658
+ timelineDetail('Required earlier work did not pass; the planner chose a recovery path', width),
659
+ ], Number.MAX_SAFE_INTEGER, 'execution');
660
+ }
661
+ continue;
662
+ }
663
+ add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width), Number.MAX_SAFE_INTEGER, 'execution');
642
664
  const finished = actions
643
665
  .filter((action) => actionFinishedAt(state, action) && TERMINAL_ACTIONS.has(effectiveActionStatus(action, state)))
644
666
  .sort((a, b) => Date.parse(actionFinishedAt(state, a)) - Date.parse(actionFinishedAt(state, b)));
@@ -647,29 +669,33 @@ function workflowTimelineLines(model, width) {
647
669
  const branch = terminalPhase ? '│ └─' : '│ ├─';
648
670
  const actionFinished = actionFinishedAt(state, action);
649
671
  const actionStarted = actionStartedAt(state, action);
672
+ const blocked = state.outputs?.[action.id]?.dependencyBlocked === true;
650
673
  add(actionFinished, timelineRow(
651
674
  actionFinished,
652
- `${branch}${statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
675
+ `${branch}${blocked ? '⊘' : statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
653
676
  actionStarted ? durationText(actionStarted, actionFinished) : '',
654
677
  width,
655
- ));
678
+ ), Number.MAX_SAFE_INTEGER, 'execution');
656
679
  });
657
680
  if (finished.length === actions.length && actions.length) {
658
681
  const finishedAt = latestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
659
- const failed = actions.some((action) => String(effectiveActionStatus(action, state)).startsWith('failed'));
660
- add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : '✓'} [Phase: ${label}] completed`, `${finished.length}/${actions.length}`, width));
682
+ const blocked = actions.some((action) => state.outputs?.[action.id]?.dependencyBlocked === true);
683
+ const failed = actions.some((action) => state.outputs?.[action.id]?.dependencyBlocked !== true
684
+ && String(effectiveActionStatus(action, state)).startsWith('failed'));
685
+ const outcome = failed ? 'finished with failures' : blocked ? 'incomplete' : 'completed';
686
+ add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : blocked ? '!' : '✓'} [Phase: ${label}] ${outcome}`, `${finished.length}/${actions.length}`, width), Number.MAX_SAFE_INTEGER, 'execution');
661
687
  }
662
688
  }
663
689
 
664
690
  for (const event of model.events) {
665
691
  const detail = timelineControlEvent(event, width);
666
- if (detail) add(event.committedAt, detail, Number(event.sequence));
692
+ if (detail) add(event.committedAt, detail, Number(event.sequence), event.type.startsWith('decision.') ? 'execution' : null);
667
693
  }
668
694
 
669
695
  events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || a.sequence - b.sequence);
670
696
  const lines = [];
671
697
  events.forEach((event, index) => {
672
- if (index) lines.push('');
698
+ if (index && (!event.group || event.group !== events[index - 1].group)) lines.push('');
673
699
  lines.push(...event.lines);
674
700
  });
675
701
  return { lines: lines.length ? lines : ['Waiting for the first durable workflow milestone'], milestoneCount: events.length };
@@ -718,7 +744,7 @@ function timelineControlEvent(event, width) {
718
744
  function workflowLiveLines(model, width, spinnerFrame) {
719
745
  const { state, orchestrator } = model;
720
746
  const activeWorkers = Object.values(state.activeAgents ?? {})
721
- .filter((agent) => agent.stepId !== orchestrator.actionId)
747
+ .filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent))
722
748
  .sort((a, b) => String(b.lastEventAt ?? b.lastActivityAt ?? '').localeCompare(String(a.lastEventAt ?? a.lastActivityAt ?? '')));
723
749
  const lines = [];
724
750
  let running = activeWorkers.length + (orchestrator.active ? 1 : 0);
@@ -757,13 +783,35 @@ function workflowLiveLines(model, width, spinnerFrame) {
757
783
  if (stream) lines.push(` ${stream}`);
758
784
  lines.push('');
759
785
  }
760
- if (!lines.length) lines.push(state.finishedAt ? '✓ No live agents · workflow is terminal' : '⧖ Waiting for the next dispatch');
786
+ if (!lines.length) lines.push(state.finishedAt
787
+ ? `${statusIcon(state.status)} No agents running · ${terminalWorkflowLabel(state.status)}`
788
+ : '⧖ Waiting for the next dispatch');
761
789
  return { lines, running, waiting };
762
790
  }
763
791
 
792
+ function terminalWorkflowLabel(status) {
793
+ if (status === 'completed') return 'workflow finished';
794
+ if (status === 'completed_with_concerns') return 'workflow finished with concerns';
795
+ if (status === 'blocked') return 'workflow stopped with blockers';
796
+ if (status === 'failed') return 'workflow failed';
797
+ if (status === 'cancelled') return 'workflow cancelled';
798
+ if (status === 'interrupted') return 'workflow interrupted';
799
+ return 'workflow stopped';
800
+ }
801
+
764
802
  function workflowNextLines(model, width) {
765
803
  const { state, orchestrator } = model;
766
- if (state.finishedAt) return [truncate(`${statusIcon(state.status)} Workflow terminal · obtain the stable result envelope`, width)];
804
+ if (state.finishedAt) {
805
+ const next = state.status === 'completed' || state.status === 'completed_with_concerns'
806
+ ? 'result ready'
807
+ : state.status === 'blocked' ? 'review blockers and partial work'
808
+ : state.status === 'failed' ? 'inspect the failure before using partial work'
809
+ : state.status === 'cancelled' ? 'review any partial work'
810
+ : state.status === 'interrupted' ? 'resume the workflow or inspect partial work'
811
+ : 'inspect the workflow result';
812
+ const label = terminalWorkflowLabel(state.status);
813
+ return [truncate(`${statusIcon(state.status)} ${label[0].toUpperCase()}${label.slice(1)} · ${next}`, width)];
814
+ }
767
815
  const ledger = state.actionLedger ?? [];
768
816
  const pending = ledger.find((action) => action.id !== 'scout'
769
817
  && action.id !== orchestrator.actionId
@@ -871,7 +919,7 @@ function plannerDisplayStatus(model) {
871
919
  const { orchestrator, state } = model;
872
920
  if (state.finishedAt) return state.status === 'completed' ? 'Completed' : humanStatus(state.status);
873
921
  if (orchestrator.active) return 'Planning next actions';
874
- const workers = Object.values(state.activeAgents ?? {}).filter((agent) => agent.stepId !== orchestrator.actionId);
922
+ const workers = Object.values(state.activeAgents ?? {}).filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent));
875
923
  if (workers.length) return 'Waiting for workers';
876
924
  if (orchestrator.status === 'reviewing evidence') return 'Reviewing evidence';
877
925
  return humanStatus(orchestrator.status);
@@ -903,7 +951,7 @@ function orchestratorDetailLines(model, width, spinnerFrame, { verbose = false }
903
951
  const workerAttempts = (state.attempts ?? []).filter((attempt) => attempt.actionId !== orchestrator.actionId);
904
952
  const completedWorkers = workerAttempts.filter((attempt) => TERMINAL_ACTIONS.has(attempt.status)).length;
905
953
  const activeWorkers = Object.values(state.activeAgents ?? {})
906
- .filter((agent) => agent.stepId !== orchestrator.actionId);
954
+ .filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent));
907
955
  const nextActions = latestDecision?.actions?.map((action) => action.id).filter(Boolean) ?? [];
908
956
  const stateLabel = active
909
957
  ? 'Choosing the next smallest useful action'
@@ -111,6 +111,8 @@ export function normalizeDecisionProposal(proposal) {
111
111
 
112
112
  export function validateDecisionProposal(proposal, {
113
113
  knownActionIds = [],
114
+ requiredRequirementIds = [],
115
+ completedRequirementIds = [],
114
116
  closedPhases = [],
115
117
  currentActionCount = 0,
116
118
  maxActions = 100,
@@ -161,6 +163,8 @@ export function validateDecisionProposal(proposal, {
161
163
  }
162
164
 
163
165
  const known = new Set(knownActionIds);
166
+ const requiredRequirements = new Set(requiredRequirementIds);
167
+ const completedRequirements = new Set(completedRequirementIds);
164
168
  const proposedIds = new Set(safeActions.map((action) => action?.id).filter((id) => typeof id === 'string'));
165
169
  const closed = new Set(closedPhases);
166
170
  const proposed = new Set();
@@ -261,6 +265,21 @@ export function validateDecisionProposal(proposal, {
261
265
  }
262
266
  }
263
267
  if (action.type === 'verify') {
268
+ if (requiredRequirements.size) {
269
+ if (!Array.isArray(action.covers) || action.covers.length === 0) {
270
+ issues.push(`${at}.covers must name one or more intent requirement IDs`);
271
+ } else {
272
+ const seen = new Set();
273
+ for (const requirementId of action.covers) {
274
+ if (typeof requirementId !== 'string' || !requiredRequirements.has(requirementId)) {
275
+ issues.push(`${at}.covers references unknown requirement "${requirementId}"`);
276
+ } else if (seen.has(requirementId)) {
277
+ issues.push(`${at}.covers repeats requirement "${requirementId}"`);
278
+ }
279
+ seen.add(requirementId);
280
+ }
281
+ }
282
+ }
264
283
  if (action.review == null && action.reviewScope === 'repository') {
265
284
  // Normalized zero-dependency audit: no artifact to review.
266
285
  } else if (typeof action.review !== 'string') {
@@ -274,6 +293,9 @@ export function validateDecisionProposal(proposal, {
274
293
  }
275
294
  }
276
295
  }
296
+ if (action.type !== 'verify' && action.covers != null) {
297
+ issues.push(`${at}.covers is only valid on verify actions`);
298
+ }
277
299
  }
278
300
  if (proposedItems > maxItemsPerExpansion) {
279
301
  issues.push(`proposal has ${proposedItems} fanout items, exceeding maxItemsPerExpansion=${maxItemsPerExpansion}`);
@@ -300,6 +322,15 @@ export function validateDecisionProposal(proposal, {
300
322
  };
301
323
  for (const id of proposed) visit(id);
302
324
 
325
+ if (requiredRequirements.size && (proposal.completion || proposal.decision === 'complete')) {
326
+ const covered = new Set(completedRequirements);
327
+ for (const action of safeActions.filter((entry) => entry?.type === 'verify')) {
328
+ for (const requirementId of action.covers ?? []) covered.add(requirementId);
329
+ }
330
+ const missing = [...requiredRequirements].filter((id) => !covered.has(id));
331
+ if (missing.length) issues.push(`completion lacks verifier coverage for ${missing.join(', ')}`);
332
+ }
333
+
303
334
  if (issues.length) throw new DecisionValidationError(issues);
304
335
  return {
305
336
  schemaVersion: DECISION_SCHEMA_VERSION,
@@ -16,7 +16,7 @@ export const PLANNER_RULES_SECTION = [
16
16
  '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.',
17
17
  '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.',
18
18
  '7. Put verify.repair on every verify. ok:false means unusable: the goal\'s acceptance command fails, a deliverable is missing, or the answer is nonsense; everything else is a concern under ok:true (style, cosmetic mismatches, later-scheduled work, files other actions changed, and any process rule the goal does not state such as append-only or tests untouched). ok:false is repaired and re-checked inside the program; the repair edits only its unit\'s files and cannot rewrite the answer under review, so report a wrong claim as a concern with the true value.',
19
- '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
+ '8. Every verify declares covers:["R1",...] from intent.requirements. Before completion, successful verifies must cover every requirement with runtime evidence against the ORIGINAL goal, not a reduced plan. Add completion with all-actions-ok when that verified program finishes the goal; return complete rather than adding polish. The program\'s LAST worker must be covered by a successful verify. Never proceed, never ask the user, and stop only for a concrete unresolved blocker.',
20
20
  '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.',
21
21
  '10. Never propose pool, model, 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).',
22
22
  'Shared tree: run substantial DISJOINT units concurrently; order shared files after feeders with dependsOn. Workers and unit verifies use focused commands, never the full suite while siblings write; run the suite once in the final verify after edits and repairs. Reuse it unless code changed. operatorSteering applies within the original intent and cannot weaken verification or expand authority.',
@@ -24,12 +24,47 @@ export const PLANNER_RULES_SECTION = [
24
24
 
25
25
  export const PLANNER_EXAMPLES_SECTION = [
26
26
  'Action shapes:',
27
- '[{"type":"run","phase":"implement","prompt":"..."},{"type":"run","phase":"inventory","lane":"chore","effort":"low","prompt":"... RETURN ONLY a JSON object.","outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}}},"required":["items"]}},{"type":"fanout","phase":"fix","items":["alpha"],"stepTemplate":{"prompt":"Handle {{item}}."}},{"type":"verify","phase":"verify","lane":"analyze","prompt":"Check the artifact.","repair":{"prompt":"Fix rejected concerns.","maxRounds":1}}]',
27
+ '[{"type":"run","phase":"implement","prompt":"..."},{"type":"run","phase":"inventory","lane":"chore","effort":"low","prompt":"... RETURN ONLY a JSON object.","outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}}},"required":["items"]}},{"type":"fanout","phase":"fix","items":["alpha"],"stepTemplate":{"prompt":"Handle {{item}}."}},{"type":"verify","phase":"verify","lane":"analyze","covers":["R1"],"prompt":"Check the artifact.","repair":{"prompt":"Fix rejected concerns.","maxRounds":1}}]',
28
28
  'Complete program (tests depend on fix, not verify-fix, so both run at once):',
29
- '{"actions":[{"id":"discover","type":"run","phase":"discover","lane":"chore","effort":"low","prompt":"In /abs/repo list modules needing work; RETURN ONLY a JSON object with an items array.","outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}}},"required":["items"]}},{"id":"fix","type":"fanout","phase":"fix","itemsFrom":"outputs.discover.data.items","dependsOn":["discover"],"stepTemplate":{"prompt":"In /abs/repo edit only src/{{item}}.js and run node --test tests/{{item}}.test.js."}},{"id":"verify-fix","type":"verify","phase":"verify","dependsOn":["fix"],"prompt":"Check each fixed module against the spec.","repair":{"prompt":"Fix rejected concerns in /abs/repo and rerun that module\'s test.","maxRounds":2}},{"id":"tests","type":"fanout","phase":"tests","itemsFrom":"outputs.discover.data.items","dependsOn":["fix"],"stepTemplate":{"prompt":"In /abs/repo write only tests/{{item}}.guards.test.js and run node --test on it."}},{"id":"verify-tests","type":"verify","phase":"verify","dependsOn":["tests"],"prompt":"Check the new tests are non-vacuous.","repair":{"prompt":"Fix rejected tests in /abs/repo.","maxRounds":1}},{"id":"verify-suite","type":"verify","phase":"verify","dependsOn":["verify-fix","verify-tests"],"effort":"low","prompt":"Run npm test in /abs/repo.","repair":{"prompt":"Fix the suite failure in /abs/repo and rerun it.","maxRounds":1}},{"id":"report","type":"run","phase":"report","lane":"chore","effort":"low","dependsOn":["verify-suite"],"prompt":"In /abs/repo list each changed file with a reason and quote the suite tail; plain markdown."},{"id":"verify-report","type":"verify","phase":"report","dependsOn":["report"],"prompt":"Check each claim against git status and a fresh suite run; a wrong number is a concern with the true value.","repair":{"prompt":"Fix any real repository defect in /abs/repo.","maxRounds":1}}],"completion":{"when":"all-actions-ok","reason":"Fix, tests, suite and report are each verified."}}',
29
+ '{"actions":[{"id":"discover","type":"run","phase":"discover","prompt":"In /abs/repo list modules needing work; RETURN ONLY a JSON object with an items array.","outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}}},"required":["items"]}},{"id":"fix","type":"fanout","phase":"fix","itemsFrom":"outputs.discover.data.items","dependsOn":["discover"],"stepTemplate":{"prompt":"In /abs/repo edit only src/{{item}}.js and run node --test tests/{{item}}.test.js."}},{"id":"verify-fix","type":"verify","phase":"verify","covers":["R1"],"dependsOn":["fix"],"prompt":"Check each fixed module against the spec.","repair":{"prompt":"Fix rejected concerns in /abs/repo and rerun that module\'s test.","maxRounds":2}},{"id":"tests","type":"fanout","phase":"tests","itemsFrom":"outputs.discover.data.items","dependsOn":["fix"],"stepTemplate":{"prompt":"In /abs/repo write only tests/{{item}}.guards.test.js and run node --test on it."}},{"id":"verify-tests","type":"verify","phase":"verify","covers":["R1"],"dependsOn":["tests"],"prompt":"Check the new tests are non-vacuous.","repair":{"prompt":"Fix rejected tests in /abs/repo.","maxRounds":1}},{"id":"verify-suite","type":"verify","phase":"verify","covers":["R1"],"dependsOn":["verify-fix","verify-tests"],"prompt":"Run npm test in /abs/repo.","repair":{"prompt":"Fix the suite failure in /abs/repo and rerun it.","maxRounds":1}},{"id":"report","type":"run","phase":"report","dependsOn":["verify-suite"],"prompt":"In /abs/repo list each changed file with a reason and quote the suite tail; plain markdown."},{"id":"verify-report","type":"verify","phase":"report","covers":["R1"],"dependsOn":["report"],"prompt":"Check each claim against git status and a fresh suite run; a wrong number is a concern with the true value.","repair":{"prompt":"Fix any real repository defect in /abs/repo.","maxRounds":1}}],"completion":{"when":"all-actions-ok","reason":"Fix, tests, suite and report are each verified."}}',
30
30
  'Rules the validator enforces: action type is run, fanout, or verify; fanout has stepTemplate and either items or itemsFrom; verify.review, when given, is outputs.<id>.outFile; ids are unique across the whole run, finished and failed actions included; dependsOn names existing or proposed actions; lane is analyze|build|chore and effort is low|medium|high; runtime-owned fields are rejected.',
31
31
  ].join('\n');
32
32
 
33
+ function compactRequirement(text, max = 600) {
34
+ const compact = String(text ?? '').replace(/\s+/g, ' ').trim();
35
+ return compact.length > max ? `${compact.slice(0, max - 1)}…` : compact;
36
+ }
37
+
38
+ export function extractGoalRequirements(goal) {
39
+ const text = String(goal ?? '').trim();
40
+ if (!text) return [];
41
+ const lines = text.split(/\r?\n/);
42
+ const numbered = [];
43
+ let current = null;
44
+ for (const raw of lines) {
45
+ const line = raw.trim();
46
+ const match = /^(\d+)[.)]\s+(.+)$/.exec(line);
47
+ if (match) {
48
+ if (current) numbered.push(current);
49
+ current = { number: match[1], text: match[2] };
50
+ } else if (current && line && !/^(?:finish with|before completion|acceptance(?: criteria)?|finally)\b/i.test(line)) {
51
+ current.text += ` ${line}`;
52
+ }
53
+ }
54
+ if (current) numbered.push(current);
55
+ const requirements = numbered.map((entry, index) => ({
56
+ id: `R${index + 1}`,
57
+ text: compactRequirement(entry.text),
58
+ }));
59
+ const finalLines = lines
60
+ .map((line) => line.trim())
61
+ .filter((line) => /^(?:finish with|before completion|acceptance(?: criteria)?|finally)\b/i.test(line));
62
+ if (finalLines.length) {
63
+ requirements.push({ id: `R${requirements.length + 1}`, text: compactRequirement(finalLines.join(' ')) });
64
+ }
65
+ return requirements.length ? requirements : [{ id: 'R1', text: compactRequirement(text) }];
66
+ }
67
+
33
68
  export const AUTONOMOUS_ORCHESTRATOR_PROMPT = [
34
69
  'You are the autonomous orchestrator for the user goal in the durable workflow context.',
35
70
  'This is a control-plane decision thread. Compile the goal into a complete workflow program, own decomposition through independent verification, and use only the supplied context. Do not invoke Bullswarm, run shell commands, call tools, modify files, or ask the user to steer routine execution.',
@@ -133,6 +168,7 @@ export function buildGoalWorkflow({
133
168
  description: 'Autonomous goal-driven workflow generated by Bullswarm.',
134
169
  intent: {
135
170
  goal: goal.trim(),
171
+ requirements: extractGoalRequirements(goal),
136
172
  cwd: targetDir,
137
173
  autonomous: true,
138
174
  requestedOrchestrator: orchestrator ?? 'auto',
@@ -505,7 +505,19 @@ export function verifiesWorker(verify, worker) {
505
505
  && new RegExp(`^${verify.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-repair-\\d+$`).test(worker.id);
506
506
  }
507
507
 
508
- export function completionEvidenceGaps(dynamicActions, policy, outputs = {}) {
508
+ export function completedRequirementIds(dynamicActions, outputs = {}) {
509
+ const completed = new Set();
510
+ for (const action of dynamicActions) {
511
+ if (action.kind !== 'verify' || action.status !== 'succeeded' || !actionOutputOk(action, outputs)) continue;
512
+ for (const id of action.covers ?? []) {
513
+ const evidence = outputs?.[action.id]?.verify?.requirements?.[id];
514
+ if (evidence?.ok === true && typeof evidence.evidence === 'string' && evidence.evidence.trim()) completed.add(id);
515
+ }
516
+ }
517
+ return [...completed];
518
+ }
519
+
520
+ export function completionEvidenceGaps(dynamicActions, policy, outputs = {}, requirements = []) {
509
521
  const missing = [];
510
522
  const successfulWorkers = dynamicActions.filter(
511
523
  (action) => action.kind !== 'verify' && action.status === 'succeeded'
@@ -526,9 +538,20 @@ export function completionEvidenceGaps(dynamicActions, policy, outputs = {}) {
526
538
  ? `a successful verification of latest worker ${latestSuccessfulWorker.id}`
527
539
  : 'a successful verification action');
528
540
  }
541
+ const covered = new Set(completedRequirementIds(dynamicActions, outputs));
542
+ const missingRequirements = requirements.filter((entry) => !covered.has(entry.id)).map((entry) => entry.id);
543
+ if (missingRequirements.length) missing.push(`verified original-goal coverage for ${missingRequirements.join(', ')}`);
529
544
  return missing;
530
545
  }
531
546
 
547
+ function successfulVerifyConcerns(dynamicActions, outputs = {}) {
548
+ return [...new Set(dynamicActions
549
+ .filter((action) => action.kind === 'verify' && action.status === 'succeeded' && actionOutputOk(action, outputs))
550
+ .flatMap((action) => Array.isArray(outputs?.[action.id]?.verify?.concerns)
551
+ ? outputs[action.id].verify.concerns.map(String) : [])
552
+ .filter(Boolean))];
553
+ }
554
+
532
555
  function plannerCorrectionAllowance(settings) {
533
556
  const value = Number(settings?.maxPlannerCorrections);
534
557
  if (!Number.isFinite(value)) return 2;
@@ -553,6 +576,7 @@ function terminalPlannerOutcome(state, gate, reason) {
553
576
  dynamicActions,
554
577
  state.orchestration?.completionPolicy,
555
578
  state.outputs,
579
+ state.intent?.requirements ?? [],
556
580
  );
557
581
  for (const action of observedActions.filter((entry) => entry.kind === 'verify')) {
558
582
  const output = state.outputs?.[action.id];
@@ -964,6 +988,11 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
964
988
  }
965
989
  proposal = validateDecisionProposal(normalizedProposal, {
966
990
  knownActionIds: (state.plan?.actions ?? []).map((action) => action.id),
991
+ requiredRequirementIds: (state.intent?.requirements ?? []).map((entry) => entry.id),
992
+ completedRequirementIds: completedRequirementIds(
993
+ (state.actionLedger ?? []).filter((action) => action.parentId === gate.id),
994
+ state.outputs,
995
+ ),
967
996
  closedPhases: (state.plan?.actions ?? [])
968
997
  .filter((action) => action.source === 'planner')
969
998
  .map((action) => action.definition?.phase)
@@ -1071,7 +1100,7 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
1071
1100
  if (proposal.decision === 'complete') {
1072
1101
  const policy = state.orchestration?.completionPolicy;
1073
1102
  const dynamicActions = (state.actionLedger ?? []).filter((action) => action.parentId === gate.id);
1074
- const missing = completionEvidenceGaps(dynamicActions, policy, state.outputs);
1103
+ const missing = completionEvidenceGaps(dynamicActions, policy, state.outputs, state.intent?.requirements ?? []);
1075
1104
  if (missing.length) {
1076
1105
  const why = `autonomous completion rejected: missing ${missing.join(' and ')}`;
1077
1106
  decision.accepted = false;
@@ -1081,16 +1110,18 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
1081
1110
  runtime.persist();
1082
1111
  continue;
1083
1112
  }
1113
+ const concerns = successfulVerifyConcerns(dynamicActions, state.outputs);
1114
+ const terminalStatus = concerns.length ? 'completed_with_concerns' : 'completed';
1084
1115
  state.outcome = {
1085
- status: 'completed',
1116
+ status: terminalStatus,
1086
1117
  verified: true,
1087
1118
  bestEffort: false,
1088
1119
  reason: proposal.reason,
1089
- concerns: [],
1120
+ concerns,
1090
1121
  deliveryActionId: dynamicActions.filter((action) =>
1091
1122
  action.kind !== 'verify' && actionOutputOk(action, state.outputs)).at(-1)?.id ?? null,
1092
1123
  };
1093
- return { ok: true, why: proposal.reason, complete: true, decision: proposal };
1124
+ return { ok: true, why: proposal.reason, complete: true, terminalStatus, decision: proposal };
1094
1125
  }
1095
1126
  if (proposal.decision === 'proceed') return { ok: true, why: proposal.reason, decision: proposal };
1096
1127
  if (proposal.decision === 'stop') {
@@ -1152,7 +1183,12 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
1152
1183
  .filter((entry) => !ledgerById.has(entry.id) || !actionOutputOk(ledgerById.get(entry.id), state.outputs))
1153
1184
  .map((entry) => entry.id);
1154
1185
  const dynamicActions = (state.actionLedger ?? []).filter((action) => action.parentId === gate.id);
1155
- const gaps = failing.length ? [] : completionEvidenceGaps(dynamicActions, state.orchestration?.completionPolicy, state.outputs);
1186
+ const gaps = failing.length ? [] : completionEvidenceGaps(
1187
+ dynamicActions,
1188
+ state.orchestration?.completionPolicy,
1189
+ state.outputs,
1190
+ state.intent?.requirements ?? [],
1191
+ );
1156
1192
  // Pending operator steering blocks self-completion: the documented
1157
1193
  // contract is delivery at the next planner gate, so a clean program
1158
1194
  // returns to the planner (which delivers the steer) instead of
@@ -1182,19 +1218,21 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
1182
1218
  gateId: gate.id, sequence: auto.sequence, programSequence: decision.sequence,
1183
1219
  actions: programActions.map((entry) => entry.id), reason,
1184
1220
  });
1221
+ const concerns = successfulVerifyConcerns(dynamicActions, state.outputs);
1222
+ const terminalStatus = concerns.length ? 'completed_with_concerns' : 'completed';
1185
1223
  state.outcome = {
1186
- status: 'completed',
1224
+ status: terminalStatus,
1187
1225
  verified: true,
1188
1226
  bestEffort: false,
1189
1227
  reason,
1190
- concerns: [],
1228
+ concerns,
1191
1229
  deliveryActionId: dynamicActions.filter((action) =>
1192
1230
  action.kind !== 'verify' && actionOutputOk(action, state.outputs)).at(-1)?.id ?? null,
1193
1231
  source: 'program-completion',
1194
1232
  };
1195
1233
  state.outputs[gate.id] = { ...state.outputs[gate.id], ok: true, why: reason, autoCompleted: true };
1196
1234
  runtime.persist();
1197
- return { ok: true, why: reason, complete: true, decision: { decision: 'complete', reason, actions: [], completion: proposal.completion } };
1235
+ return { ok: true, why: reason, complete: true, terminalStatus, decision: { decision: 'complete', reason, actions: [], completion: proposal.completion } };
1198
1236
  }
1199
1237
  if (pendingSteering.length) {
1200
1238
  runtime.emit('decision.completion_deferred', {
@@ -56,6 +56,78 @@ export const BURST_WAIT_GRACE_MS = 10 * 60_000;
56
56
  export const BURST_WAIT_UNKNOWN_RESET_MS = 5 * 3600_000;
57
57
  export const QUOTA_POLL_MS = 60_000;
58
58
 
59
+ export function enforceVerifyRequirementCoverage(parsed, requiredCoverage = []) {
60
+ if (!parsed || !requiredCoverage.length) return { parsed, concerns: [] };
61
+ const normalized = { ...parsed };
62
+ normalized.requirements = parsed.requirements && typeof parsed.requirements === 'object'
63
+ ? parsed.requirements : {};
64
+ const concerns = [];
65
+ for (const requirement of requiredCoverage) {
66
+ const evidence = normalized.requirements[requirement.id];
67
+ if (!evidence || evidence.ok !== true || typeof evidence.evidence !== 'string' || !evidence.evidence.trim()) {
68
+ concerns.push(`${requirement.id} lacks passing, specific verification evidence: ${requirement.text}`);
69
+ }
70
+ }
71
+ if (concerns.length) {
72
+ normalized.ok = false;
73
+ normalized.concerns = [...new Set([...(Array.isArray(parsed.concerns) ? parsed.concerns : []), ...concerns])];
74
+ }
75
+ return { parsed: normalized, concerns };
76
+ }
77
+
78
+ export function parseVerifyJsonText(text) {
79
+ const source = String(text ?? '').trim();
80
+ const starts = [];
81
+ for (let index = 0; index < source.length; index++) {
82
+ if (source[index] === '{') starts.push(index);
83
+ }
84
+ let parseError = null;
85
+ for (const start of starts) {
86
+ const suffix = source.slice(start);
87
+ const candidates = [];
88
+ const lastClose = suffix.lastIndexOf('}');
89
+ if (lastClose > 0) candidates.push(suffix.slice(0, lastClose + 1));
90
+ const repaired = closeTruncatedJsonObject(suffix);
91
+ if (repaired && !candidates.includes(repaired)) candidates.push(repaired);
92
+ for (const candidate of candidates) {
93
+ try {
94
+ const parsed = JSON.parse(candidate);
95
+ if (parsed && typeof parsed === 'object'
96
+ && typeof parsed.ok === 'boolean'
97
+ && Array.isArray(parsed.concerns)
98
+ && typeof parsed.summary === 'string') {
99
+ return { parsed, parseError: null };
100
+ }
101
+ } catch (err) {
102
+ parseError = err.message;
103
+ }
104
+ }
105
+ }
106
+ return { parsed: null, parseError };
107
+ }
108
+
109
+ function closeTruncatedJsonObject(source) {
110
+ const stack = [];
111
+ let quoted = false;
112
+ let escaped = false;
113
+ for (const char of source) {
114
+ if (quoted) {
115
+ if (escaped) escaped = false;
116
+ else if (char === '\\') escaped = true;
117
+ else if (char === '"') quoted = false;
118
+ continue;
119
+ }
120
+ if (char === '"') quoted = true;
121
+ else if (char === '{') stack.push('}');
122
+ else if (char === '[') stack.push(']');
123
+ else if (char === '}' || char === ']') {
124
+ if (stack.pop() !== char) return null;
125
+ }
126
+ }
127
+ if (quoted || !stack.length) return null;
128
+ return `${source}${stack.reverse().join('')}`;
129
+ }
130
+
59
131
  export function plannerBudgetContext(budget = {}) {
60
132
  const dispatchesUsedBeforePlanner = Number(budget.dispatchesUsed ?? 0);
61
133
  const rawTarget = budget.dispatchTarget ?? budget.dispatchLimit;
@@ -239,6 +311,7 @@ export class WorkflowRuntime {
239
311
  parentId: opts.itemIndex == null ? (step.parentId ?? null) : step.id,
240
312
  kind: opts.itemIndex == null ? step.type : 'run',
241
313
  dependsOn: [...(step.dependsOn ?? [])],
314
+ ...(step.type === 'verify' && Array.isArray(step.covers) ? { covers: [...step.covers] } : {}),
242
315
  status: 'queued',
243
316
  phase: opts.phase ?? null,
244
317
  item: opts.item,
@@ -1127,6 +1200,22 @@ export class WorkflowRuntime {
1127
1200
  })();
1128
1201
 
1129
1202
  const reverify = step._reverify && typeof step._reverify === 'object' ? step._reverify : null;
1203
+ const requiredCoverage = Array.isArray(step.covers)
1204
+ ? step.covers.map((id) => this.state.intent?.requirements?.find((entry) => entry.id === id)).filter(Boolean)
1205
+ : [];
1206
+ const coverageVerdictShape = requiredCoverage.length
1207
+ ? JSON.stringify(Object.fromEntries(requiredCoverage.map((entry) => [entry.id, { ok: true, evidence: 'specific evidence' }])))
1208
+ : null;
1209
+ const coverageContract = requiredCoverage.length ? [
1210
+ '',
1211
+ 'ORIGINAL USER GOAL (runtime-owned; it overrides any narrower planner or worker scope):',
1212
+ this.state.intent?.goal ?? '(goal unavailable)',
1213
+ '',
1214
+ 'REQUIRED COVERAGE FOR THIS VERIFIER:',
1215
+ ...requiredCoverage.map((entry) => `- ${entry.id}: ${entry.text}`),
1216
+ 'A missing explicitly required deliverable is unusable and MUST produce ok:false.',
1217
+ 'For every ID above, include requirements.<ID> = {"ok":true|false,"evidence":"specific file, symbol, command, or observed result"}. Empty or generic evidence is invalid.',
1218
+ ] : [];
1130
1219
  const reviewInstructions = [
1131
1220
  step.prompt ?? 'You are a skeptical reviewer. Independently inspect the work and its current repository state.',
1132
1221
  ...(reverify ? [
@@ -1139,9 +1228,12 @@ export class WorkflowRuntime {
1139
1228
  ] : []),
1140
1229
  '',
1141
1230
  'Acceptance standard (runtime-owned; it overrides any stricter rule in the instructions above): ok:false means the work is unusable: its acceptance command fails, a required deliverable is missing, or the answer is nonsense or contradicts its own evidence. Everything else goes in concerns under ok:true: style, wording, scope, cosmetic mismatches, process rules the goal never stated (append-only, diff size), and files changed by other actions that share this working tree, which are never this unit\'s defect. Never reject for something the goal does not require.',
1231
+ ...coverageContract,
1142
1232
  '',
1143
1233
  'RETURN ONLY a single JSON object of the form',
1144
- '{"ok": <true|false>, "concerns": [<string>...], "summary": <string>}.',
1234
+ requiredCoverage.length
1235
+ ? `{"ok": <true|false>, "concerns": [<string>...], "summary": <string>, "requirements": ${coverageVerdictShape}}.`
1236
+ : '{"ok": <true|false>, "concerns": [<string>...], "summary": <string>}.',
1145
1237
  'No prose and no markdown fences. Set ok by the acceptance standard above: true unless the work is unusable.',
1146
1238
  ].join('\n');
1147
1239
  // Only the reviewer INSTRUCTIONS are a template. The review target is a
@@ -1184,13 +1276,7 @@ export class WorkflowRuntime {
1184
1276
  const parseVerdictFile = (path) => {
1185
1277
  try {
1186
1278
  const out = readFileSync(path, 'utf8');
1187
- const start = out.indexOf('{');
1188
- const end = out.lastIndexOf('}');
1189
- if (start >= 0 && end > start) {
1190
- const j = JSON.parse(out.slice(start, end + 1));
1191
- if (j && typeof j === 'object') return { parsed: j, parseError: null };
1192
- }
1193
- return { parsed: null, parseError: null };
1279
+ return parseVerifyJsonText(out);
1194
1280
  } catch (err) {
1195
1281
  return { parsed: null, parseError: err.message };
1196
1282
  }
@@ -1204,16 +1290,22 @@ export class WorkflowRuntime {
1204
1290
  // same economics that give outputSchema its single retry.
1205
1291
  if (verdict.ok && !parsed) {
1206
1292
  this.emit('verify.verdict_retry', { actionId: step.id, why: parseError ?? 'no JSON object in verdict' });
1207
- const retryVerdict = await this.dispatch(step, [
1208
- taskText,
1209
- '',
1210
- `Your previous reply could not be used: ${parseError ?? 'it did not contain a parseable JSON object'}.`,
1211
- 'Do the review again if needed, then RETURN ONLY the single JSON object {"ok": <true|false>, "concerns": [...], "summary": "..."} — no prose, no markdown fences, nothing before "{" or after "}".',
1212
- ].join('\n'), targetDir, paths, {
1213
- escalate: this.state.settings.escalateOnFail !== false,
1214
- retryAttempts: 0,
1215
- phase: opts.phase,
1216
- });
1293
+ let retryVerdict;
1294
+ try {
1295
+ retryVerdict = await this.dispatch(step, [
1296
+ taskText,
1297
+ '',
1298
+ `Your previous reply could not be used: ${parseError ?? 'it did not contain a parseable JSON object'}.`,
1299
+ `Do the review again if needed, then RETURN ONLY the single JSON object ${requiredCoverage.length ? `{"ok":true,"concerns":[],"summary":"...","requirements":${coverageVerdictShape}}` : '{"ok": <true|false>, "concerns": [...], "summary": "..."}'} — no prose, no markdown fences, nothing before "{" or after "}".`,
1300
+ ].join('\n'), targetDir, paths, {
1301
+ escalate: this.state.settings.escalateOnFail !== false,
1302
+ retryAttempts: 0,
1303
+ phase: opts.phase,
1304
+ });
1305
+ } finally {
1306
+ delete this.state.activeAgents?.[step.id];
1307
+ this.persist();
1308
+ }
1217
1309
  if (retryVerdict.ok) {
1218
1310
  verdict = retryVerdict;
1219
1311
  finalPaths.taskFile = retryVerdict.taskFile ?? finalPaths.taskFile;
@@ -1222,6 +1314,7 @@ export class WorkflowRuntime {
1222
1314
  }
1223
1315
  }
1224
1316
 
1317
+ ({ parsed } = enforceVerifyRequirementCoverage(parsed, requiredCoverage));
1225
1318
  const ok = verdict.ok && !!parsed && parsed.ok === true;
1226
1319
  const verifyVerdict = {
1227
1320
  ok,