bullswarm 0.18.0 → 0.18.1

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,22 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.18.1 — original-goal verification and denser timeline
4
+
5
+ - Goal workflows now derive a durable requirement ledger from the original
6
+ user goal. Planner verify actions declare which requirements they cover, and
7
+ neither explicit nor program-level completion is accepted until every
8
+ requirement has a successful verifier with specific evidence. The verifier
9
+ receives the original goal from the runtime, so a reduced planner scope can
10
+ no longer silently omit requested APIs, events, tests, or documentation.
11
+ - Successful verifier concerns are preserved in a verified
12
+ `completed_with_concerns` result instead of being discarded or triggering
13
+ unnecessary follow-up spending.
14
+ - The human timeline calls its first accepted planner decision `plan created`,
15
+ later decisions `plan updated`, and the final one `completion confirmed`.
16
+ Execution milestones are rendered as one dense block without blank rows.
17
+ Finished workflows now say `No agents running · workflow finished` and
18
+ `Workflow finished · result ready` instead of control-plane terminology.
19
+
3
20
  ## 0.18.0 — exact routes, cheaper plans, clearer results
4
21
 
5
22
  - `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.1",
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": {
@@ -588,9 +588,9 @@ function workflowTimelineLines(model, width) {
588
588
  const { state, orchestrator } = model;
589
589
  const ledger = state.actionLedger ?? [];
590
590
  const events = [];
591
- const add = (at, lines, sequence = Number.MAX_SAFE_INTEGER) => {
591
+ const add = (at, lines, sequence = Number.MAX_SAFE_INTEGER, group = null) => {
592
592
  if (!at) return;
593
- events.push({ at, sequence, lines: Array.isArray(lines) ? lines : [lines] });
593
+ events.push({ at, sequence, group, lines: Array.isArray(lines) ? lines : [lines] });
594
594
  };
595
595
  const scout = ledger.find((action) => action.id === 'scout');
596
596
  add(state.startedAt, [
@@ -620,10 +620,17 @@ function workflowTimelineLines(model, width) {
620
620
  const decision = decisionForPlannerAttempt(state, attempt, index, orchestrator.attempts);
621
621
  const summary = decision?.reason ? sentencePreview(decision.reason, Math.max(30, width - 10))
622
622
  : decision ? decisionLabel(decision.decision) : 'No accepted decision; correction or retry turn';
623
+ const acceptedBefore = orchestrator.attempts.slice(0, index).filter((entry, priorIndex) =>
624
+ decisionForPlannerAttempt(state, entry, priorIndex, orchestrator.attempts)).length;
625
+ const plannerLabel = !decision
626
+ ? `planning retry #${index + 1}`
627
+ : decision.decision === 'complete'
628
+ ? 'completion confirmed'
629
+ : acceptedBefore === 0 ? 'plan created' : `plan updated #${acceptedBefore + 1}`;
623
630
  add(attempt.finishedAt, [
624
- timelineRow(attempt.finishedAt, `◆ [Workflow Planner] checkpoint #${index + 1}`, durationText(attempt.startedAt, attempt.finishedAt), width),
631
+ timelineRow(attempt.finishedAt, `◆ [Workflow Planner] ${plannerLabel}`, durationText(attempt.startedAt, attempt.finishedAt), width),
625
632
  timelineDetail(summary, width),
626
- ]);
633
+ ], Number.MAX_SAFE_INTEGER, 'execution');
627
634
  });
628
635
 
629
636
  const phases = new Map();
@@ -638,7 +645,7 @@ function workflowTimelineLines(model, width) {
638
645
  const startedAt = realStart ?? earliestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
639
646
  if (!startedAt) continue;
640
647
  const label = phaseLabel(name, orchestrator);
641
- add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width));
648
+ add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width), Number.MAX_SAFE_INTEGER, 'execution');
642
649
  const finished = actions
643
650
  .filter((action) => actionFinishedAt(state, action) && TERMINAL_ACTIONS.has(effectiveActionStatus(action, state)))
644
651
  .sort((a, b) => Date.parse(actionFinishedAt(state, a)) - Date.parse(actionFinishedAt(state, b)));
@@ -652,24 +659,24 @@ function workflowTimelineLines(model, width) {
652
659
  `${branch}${statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
653
660
  actionStarted ? durationText(actionStarted, actionFinished) : '',
654
661
  width,
655
- ));
662
+ ), Number.MAX_SAFE_INTEGER, 'execution');
656
663
  });
657
664
  if (finished.length === actions.length && actions.length) {
658
665
  const finishedAt = latestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
659
666
  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));
667
+ add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : '✓'} [Phase: ${label}] completed`, `${finished.length}/${actions.length}`, width), Number.MAX_SAFE_INTEGER, 'execution');
661
668
  }
662
669
  }
663
670
 
664
671
  for (const event of model.events) {
665
672
  const detail = timelineControlEvent(event, width);
666
- if (detail) add(event.committedAt, detail, Number(event.sequence));
673
+ if (detail) add(event.committedAt, detail, Number(event.sequence), event.type.startsWith('decision.') ? 'execution' : null);
667
674
  }
668
675
 
669
676
  events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || a.sequence - b.sequence);
670
677
  const lines = [];
671
678
  events.forEach((event, index) => {
672
- if (index) lines.push('');
679
+ if (index && (!event.group || event.group !== events[index - 1].group)) lines.push('');
673
680
  lines.push(...event.lines);
674
681
  });
675
682
  return { lines: lines.length ? lines : ['Waiting for the first durable workflow milestone'], milestoneCount: events.length };
@@ -757,13 +764,35 @@ function workflowLiveLines(model, width, spinnerFrame) {
757
764
  if (stream) lines.push(` ${stream}`);
758
765
  lines.push('');
759
766
  }
760
- if (!lines.length) lines.push(state.finishedAt ? '✓ No live agents · workflow is terminal' : '⧖ Waiting for the next dispatch');
767
+ if (!lines.length) lines.push(state.finishedAt
768
+ ? `${statusIcon(state.status)} No agents running · ${terminalWorkflowLabel(state.status)}`
769
+ : '⧖ Waiting for the next dispatch');
761
770
  return { lines, running, waiting };
762
771
  }
763
772
 
773
+ function terminalWorkflowLabel(status) {
774
+ if (status === 'completed') return 'workflow finished';
775
+ if (status === 'completed_with_concerns') return 'workflow finished with concerns';
776
+ if (status === 'blocked') return 'workflow stopped with blockers';
777
+ if (status === 'failed') return 'workflow failed';
778
+ if (status === 'cancelled') return 'workflow cancelled';
779
+ if (status === 'interrupted') return 'workflow interrupted';
780
+ return 'workflow stopped';
781
+ }
782
+
764
783
  function workflowNextLines(model, width) {
765
784
  const { state, orchestrator } = model;
766
- if (state.finishedAt) return [truncate(`${statusIcon(state.status)} Workflow terminal · obtain the stable result envelope`, width)];
785
+ if (state.finishedAt) {
786
+ const next = state.status === 'completed' || state.status === 'completed_with_concerns'
787
+ ? 'result ready'
788
+ : state.status === 'blocked' ? 'review blockers and partial work'
789
+ : state.status === 'failed' ? 'inspect the failure before using partial work'
790
+ : state.status === 'cancelled' ? 'review any partial work'
791
+ : state.status === 'interrupted' ? 'resume the workflow or inspect partial work'
792
+ : 'inspect the workflow result';
793
+ const label = terminalWorkflowLabel(state.status);
794
+ return [truncate(`${statusIcon(state.status)} ${label[0].toUpperCase()}${label.slice(1)} · ${next}`, width)];
795
+ }
767
796
  const ledger = state.actionLedger ?? [];
768
797
  const pending = ledger.find((action) => action.id !== 'scout'
769
798
  && action.id !== orchestrator.actionId
@@ -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,25 @@ 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
+
59
78
  export function plannerBudgetContext(budget = {}) {
60
79
  const dispatchesUsedBeforePlanner = Number(budget.dispatchesUsed ?? 0);
61
80
  const rawTarget = budget.dispatchTarget ?? budget.dispatchLimit;
@@ -239,6 +258,7 @@ export class WorkflowRuntime {
239
258
  parentId: opts.itemIndex == null ? (step.parentId ?? null) : step.id,
240
259
  kind: opts.itemIndex == null ? step.type : 'run',
241
260
  dependsOn: [...(step.dependsOn ?? [])],
261
+ ...(step.type === 'verify' && Array.isArray(step.covers) ? { covers: [...step.covers] } : {}),
242
262
  status: 'queued',
243
263
  phase: opts.phase ?? null,
244
264
  item: opts.item,
@@ -1127,6 +1147,22 @@ export class WorkflowRuntime {
1127
1147
  })();
1128
1148
 
1129
1149
  const reverify = step._reverify && typeof step._reverify === 'object' ? step._reverify : null;
1150
+ const requiredCoverage = Array.isArray(step.covers)
1151
+ ? step.covers.map((id) => this.state.intent?.requirements?.find((entry) => entry.id === id)).filter(Boolean)
1152
+ : [];
1153
+ const coverageVerdictShape = requiredCoverage.length
1154
+ ? JSON.stringify(Object.fromEntries(requiredCoverage.map((entry) => [entry.id, { ok: true, evidence: 'specific evidence' }])))
1155
+ : null;
1156
+ const coverageContract = requiredCoverage.length ? [
1157
+ '',
1158
+ 'ORIGINAL USER GOAL (runtime-owned; it overrides any narrower planner or worker scope):',
1159
+ this.state.intent?.goal ?? '(goal unavailable)',
1160
+ '',
1161
+ 'REQUIRED COVERAGE FOR THIS VERIFIER:',
1162
+ ...requiredCoverage.map((entry) => `- ${entry.id}: ${entry.text}`),
1163
+ 'A missing explicitly required deliverable is unusable and MUST produce ok:false.',
1164
+ 'For every ID above, include requirements.<ID> = {"ok":true|false,"evidence":"specific file, symbol, command, or observed result"}. Empty or generic evidence is invalid.',
1165
+ ] : [];
1130
1166
  const reviewInstructions = [
1131
1167
  step.prompt ?? 'You are a skeptical reviewer. Independently inspect the work and its current repository state.',
1132
1168
  ...(reverify ? [
@@ -1139,9 +1175,12 @@ export class WorkflowRuntime {
1139
1175
  ] : []),
1140
1176
  '',
1141
1177
  '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.',
1178
+ ...coverageContract,
1142
1179
  '',
1143
1180
  'RETURN ONLY a single JSON object of the form',
1144
- '{"ok": <true|false>, "concerns": [<string>...], "summary": <string>}.',
1181
+ requiredCoverage.length
1182
+ ? `{"ok": <true|false>, "concerns": [<string>...], "summary": <string>, "requirements": ${coverageVerdictShape}}.`
1183
+ : '{"ok": <true|false>, "concerns": [<string>...], "summary": <string>}.',
1145
1184
  'No prose and no markdown fences. Set ok by the acceptance standard above: true unless the work is unusable.',
1146
1185
  ].join('\n');
1147
1186
  // Only the reviewer INSTRUCTIONS are a template. The review target is a
@@ -1208,7 +1247,7 @@ export class WorkflowRuntime {
1208
1247
  taskText,
1209
1248
  '',
1210
1249
  `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 "}".',
1250
+ `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 "}".`,
1212
1251
  ].join('\n'), targetDir, paths, {
1213
1252
  escalate: this.state.settings.escalateOnFail !== false,
1214
1253
  retryAttempts: 0,
@@ -1222,6 +1261,7 @@ export class WorkflowRuntime {
1222
1261
  }
1223
1262
  }
1224
1263
 
1264
+ ({ parsed } = enforceVerifyRequirementCoverage(parsed, requiredCoverage));
1225
1265
  const ok = verdict.ok && !!parsed && parsed.ok === true;
1226
1266
  const verifyVerdict = {
1227
1267
  ok,