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 +17 -0
- package/package.json +1 -1
- package/src/workflow/dashboard.js +40 -11
- package/src/workflow/decision.js +31 -0
- package/src/workflow/goal.js +39 -3
- package/src/workflow/runner.js +47 -9
- package/src/workflow/runtime.js +42 -2
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
|
@@ -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]
|
|
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
|
|
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)
|
|
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
|
package/src/workflow/decision.js
CHANGED
|
@@ -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,
|
package/src/workflow/goal.js
CHANGED
|
@@ -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
|
|
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","
|
|
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',
|
package/src/workflow/runner.js
CHANGED
|
@@ -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
|
|
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:
|
|
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(
|
|
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:
|
|
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', {
|
package/src/workflow/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|