@juzser/blacksmith 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOperatorActor } from './actors.js';
|
|
1
2
|
import { EPIC_CLOSED_EVENT_TYPE } from './agents-registry.js';
|
|
2
3
|
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
3
4
|
import { foldTasks } from './db/projector.js';
|
|
@@ -9,8 +10,9 @@ import { latestIntegrationCheck } from './integration.js';
|
|
|
9
10
|
import { mcpBlockers } from './mcp.js';
|
|
10
11
|
import { bareTaskId, latestPlanVersion, livePlanTasks, loadPlan } from './plan.js';
|
|
11
12
|
import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
|
|
12
|
-
import { latestSpecReview, specReviewBlockers } from './spec.js';
|
|
13
|
-
import { TERMINAL_OK_TASK_STATUSES } from './taskStatus.js';
|
|
13
|
+
import { latestSpecReview, specReviewBlockers, taskSuccessors, } from './spec.js';
|
|
14
|
+
import { TERMINAL_OK_TASK_STATUSES, TERMINAL_TASK_STATUSES } from './taskStatus.js';
|
|
15
|
+
import { TASK_WAIVER_APPROVED_EVENT } from './waivers.js';
|
|
14
16
|
import { auditWaveConcurrency, WAVE_VERDICTS, zeroedCounts, } from './waveConcurrency.js';
|
|
15
17
|
import { RESERVED_TASK_ID } from './worktree.js';
|
|
16
18
|
/**
|
|
@@ -47,6 +49,167 @@ import { RESERVED_TASK_ID } from './worktree.js';
|
|
|
47
49
|
*/
|
|
48
50
|
/** The terminal-OK status that is a decision rather than a completion (D-120). */
|
|
49
51
|
const WAIVED_TASK_STATUS = 'waived';
|
|
52
|
+
/**
|
|
53
|
+
* A task a re-plan (`plan amend`) replaced with a successor task cut under a
|
|
54
|
+
* later plan version. `TERMINAL_TASK_STATUSES` (taskStatus.ts) already counts
|
|
55
|
+
* this as over; it is deliberately NOT in `TERMINAL_OK_TASK_STATUSES`, because
|
|
56
|
+
* "over" is not the same as "well" — the work only actually landed if the
|
|
57
|
+
* successor did. `resolveSupersededRow` below is what asks that question.
|
|
58
|
+
*/
|
|
59
|
+
const SUPERSEDED_TASK_STATUS = 'superseded';
|
|
60
|
+
/**
|
|
61
|
+
* Follows a superseded task to whatever eventually carries its work, walking
|
|
62
|
+
* a possibly multi-hop chain (a successor can itself be superseded by a
|
|
63
|
+
* later amendment). Returns the first row in the chain that is not itself
|
|
64
|
+
* superseded, or `undefined` when the chain is missing a hop, names a task
|
|
65
|
+
* with no fold record, or cycles back on itself — every one of those reads
|
|
66
|
+
* as "no successor to vouch for this", the fail-closed answer.
|
|
67
|
+
*
|
|
68
|
+
* `successors` is folded off the event log (spec.ts's `taskSuccessors`), so
|
|
69
|
+
* its keys and values are whatever `plan-version-created` recorded — not
|
|
70
|
+
* necessarily bare (D-46/P9-29) — hence comparing through `bareTaskId` at
|
|
71
|
+
* every hop rather than assuming the map is pre-normalized.
|
|
72
|
+
*/
|
|
73
|
+
function resolveSupersededRow(epicId, taskId, tasks, successors) {
|
|
74
|
+
const bareSuccessors = new Map([...successors].map(([from, to]) => [bareTaskId(epicId, from), to]));
|
|
75
|
+
const visited = new Set([bareTaskId(epicId, taskId)]);
|
|
76
|
+
let currentId = bareTaskId(epicId, taskId);
|
|
77
|
+
for (;;) {
|
|
78
|
+
const nextId = bareSuccessors.get(currentId);
|
|
79
|
+
if (nextId === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
const nextBare = bareTaskId(epicId, nextId);
|
|
82
|
+
if (visited.has(nextBare))
|
|
83
|
+
return undefined;
|
|
84
|
+
visited.add(nextBare);
|
|
85
|
+
const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === nextBare);
|
|
86
|
+
if (row === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (row.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
89
|
+
return row;
|
|
90
|
+
currentId = nextBare;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Terminal statuses that did not end well, apart from `superseded` (which is
|
|
95
|
+
* resolved through its successor instead). Derived from the two declared sets
|
|
96
|
+
* so a status classified there is classified here too.
|
|
97
|
+
*/
|
|
98
|
+
function endedNotOk(status) {
|
|
99
|
+
return (TERMINAL_TASK_STATUSES.has(status) &&
|
|
100
|
+
!TERMINAL_OK_TASK_STATUSES.has(status) &&
|
|
101
|
+
status !== SUPERSEDED_TASK_STATUS);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The blocker for an amend-pending finding with outstanding obligations. Ids
|
|
105
|
+
* that are simply not landed yet keep the original "waiting on" wording, so
|
|
106
|
+
* the common case reads exactly as before; every other id follows, in
|
|
107
|
+
* obligation order, with the reason it cannot land as things stand.
|
|
108
|
+
*/
|
|
109
|
+
function describeOutstandingObligations(findingId, version, outstanding) {
|
|
110
|
+
const target = version === undefined ? 'a recorded plan version' : `v${version}`;
|
|
111
|
+
const waiting = outstanding.filter((o) => o.reason.kind === 'pending').map((o) => o.id);
|
|
112
|
+
const parts = [];
|
|
113
|
+
if (waiting.length > 0)
|
|
114
|
+
parts.push(version === undefined
|
|
115
|
+
? `waiting on ${waiting.join(', ')} to land terminal-OK`
|
|
116
|
+
: `waiting on ${waiting.join(', ')} to land terminal-OK at v${version} or later`);
|
|
117
|
+
for (const { id, reason } of outstanding) {
|
|
118
|
+
if (reason.kind === 'pending')
|
|
119
|
+
continue;
|
|
120
|
+
if (reason.kind === 'no-row') {
|
|
121
|
+
parts.push(`${id} has no task row in the event log, so nothing can land it as things stand`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (reason.kind === 'chain-unresolved') {
|
|
125
|
+
parts.push(`${id} is superseded, but its successor chain does not resolve, so it cannot land as things stand`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const subject = reason.successorId ?? id;
|
|
129
|
+
const via = reason.successorId === undefined ? '' : `${id} is superseded by ${subject}; `;
|
|
130
|
+
switch (reason.kind) {
|
|
131
|
+
case 'successor-pending':
|
|
132
|
+
parts.push(`${via}${subject} has not landed terminal-OK (status: ${reason.status})`);
|
|
133
|
+
break;
|
|
134
|
+
case 'below-version':
|
|
135
|
+
parts.push(`${via}${subject} landed ${reason.status} at v${reason.landedAt}, below plan ` +
|
|
136
|
+
`${target}, so it needs a re-cut`);
|
|
137
|
+
break;
|
|
138
|
+
case 'no-plan-version':
|
|
139
|
+
parts.push(`${via}${subject} landed ${reason.status} but carries no plan version, so it cannot ` +
|
|
140
|
+
`be checked against ${target}`);
|
|
141
|
+
break;
|
|
142
|
+
case 'unversioned-amendment':
|
|
143
|
+
parts.push(`${via}${subject} landed ${reason.status}, but there is no amendment plan version ` +
|
|
144
|
+
'to check it against');
|
|
145
|
+
break;
|
|
146
|
+
case 'ended':
|
|
147
|
+
parts.push(`${via}${subject} ended ${reason.status} and will not land as things stand without ` +
|
|
148
|
+
'a retry or a re-cut');
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const head = version === undefined
|
|
153
|
+
? 'is amend-pending but records no plan version, so no landing can discharge it as things stand'
|
|
154
|
+
: `is amend-pending on plan v${version}`;
|
|
155
|
+
return `Finding "${findingId}" ${head}: ${parts.join('; ')}.`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The `origin` taskEvents.ts's `emitFollowUpTask` stamps on a follow-up task:
|
|
159
|
+
* one minted for a finding no open task could own, rather than one a plan cut.
|
|
160
|
+
*/
|
|
161
|
+
const FOLLOW_UP_TASK_ORIGIN = 'escalation';
|
|
162
|
+
/** The finding status that is the operator's decision not to fix (D-120). */
|
|
163
|
+
const WAIVED_FINDING_STATUS = 'waived';
|
|
164
|
+
/**
|
|
165
|
+
* The closed finding states that leave a follow-up nothing to do: waived (the
|
|
166
|
+
* operator's decision), refuted (the claim was shown false) and fix-verified
|
|
167
|
+
* (another task's fix was verified). Open, confirmed, in-flight and amend
|
|
168
|
+
* states still block.
|
|
169
|
+
*/
|
|
170
|
+
const FOLLOW_UP_CLEARING_FINDING_STATUSES = new Set([
|
|
171
|
+
WAIVED_FINDING_STATUS,
|
|
172
|
+
'refuted',
|
|
173
|
+
'fix-verified',
|
|
174
|
+
]);
|
|
175
|
+
/**
|
|
176
|
+
* Whether a follow-up task's whole reason to exist has been waived away. A
|
|
177
|
+
* follow-up is minted `todo` to own findings, and no event ever moves it to
|
|
178
|
+
* `waived` — the projector has no such write — so without this the operator
|
|
179
|
+
* waiving the only finding it owns (bs-audit-2's `followup-9a8f6ac7`) left the
|
|
180
|
+
* task holding the epic verdict forever, with nothing left to do and no verb
|
|
181
|
+
* that could say so.
|
|
182
|
+
*
|
|
183
|
+
* Deliberately narrow, the same reader-side shape as the superseded-successor
|
|
184
|
+
* rule above:
|
|
185
|
+
* - only origin `escalation`: a planned task's `todo` is a claim about work
|
|
186
|
+
* its spec still owes, which waiving a finding raised against it does not
|
|
187
|
+
* discharge;
|
|
188
|
+
* - at least one attributed finding: a follow-up that owns nothing is not
|
|
189
|
+
* "all waived", it is unexplained, and absence must not vote yes (D-126);
|
|
190
|
+
* - every attributed finding waived, refuted or fix-verified
|
|
191
|
+
* (FOLLOW_UP_CLEARING_FINDING_STATUSES): one still open, or closed any
|
|
192
|
+
* other way (expired, amended), still blocks.
|
|
193
|
+
*
|
|
194
|
+
* Attribution is `finding.task_id`. A finding routed to a follow-up is raised
|
|
195
|
+
* under it (attribution.ts's `reattributeFinding` re-mints the id before the
|
|
196
|
+
* raise), so `finding-reattributed` needs no second reading here. Ids compare
|
|
197
|
+
* bare (D-46/P9-29).
|
|
198
|
+
*
|
|
199
|
+
* Returns the findings that discharge the follow-up, or null on every branch
|
|
200
|
+
* the rule refuses, so the quorum prompt can NAME them instead of re-deriving
|
|
201
|
+
* the rule a second way.
|
|
202
|
+
*/
|
|
203
|
+
function clearedFollowUpFindings(epicId, row, findings) {
|
|
204
|
+
if (row.origin !== FOLLOW_UP_TASK_ORIGIN)
|
|
205
|
+
return null;
|
|
206
|
+
const bare = bareTaskId(epicId, row.taskId);
|
|
207
|
+
const owned = findings.filter((f) => bareTaskId(epicId, f.task_id) === bare);
|
|
208
|
+
return owned.length > 0 &&
|
|
209
|
+
owned.every((f) => FOLLOW_UP_CLEARING_FINDING_STATUSES.has(f.finding_status))
|
|
210
|
+
? owned
|
|
211
|
+
: null;
|
|
212
|
+
}
|
|
50
213
|
const GATE_OUTCOME_EVENT = 'gate-outcome';
|
|
51
214
|
const TASK_RESULT_EVENT = 'task-result-recorded';
|
|
52
215
|
/**
|
|
@@ -61,6 +224,8 @@ const TASK_RESULT_EVENT = 'task-result-recorded';
|
|
|
61
224
|
export function withGateEvidence(tasks, events, epicId) {
|
|
62
225
|
const gated = new Set();
|
|
63
226
|
const recorded = new Set();
|
|
227
|
+
// Newest plan version each task's operator approval was given under.
|
|
228
|
+
const approved = new Map();
|
|
64
229
|
for (const { record } of events) {
|
|
65
230
|
if (!record.task_id)
|
|
66
231
|
continue;
|
|
@@ -68,10 +233,23 @@ export function withGateEvidence(tasks, events, epicId) {
|
|
|
68
233
|
gated.add(bareTaskId(epicId, record.task_id));
|
|
69
234
|
else if (record.event_type === TASK_RESULT_EVENT)
|
|
70
235
|
recorded.add(bareTaskId(epicId, record.task_id));
|
|
236
|
+
// `event append` is open to any actor, so the approval counts only when
|
|
237
|
+
// the operator wrote it.
|
|
238
|
+
else if (record.event_type === TASK_WAIVER_APPROVED_EVENT && isOperatorActor(record.actor)) {
|
|
239
|
+
const bare = bareTaskId(epicId, record.task_id);
|
|
240
|
+
approved.set(bare, Math.max(approved.get(bare) ?? record.plan_version, record.plan_version));
|
|
241
|
+
}
|
|
71
242
|
}
|
|
72
243
|
return tasks.map((t) => {
|
|
73
244
|
const bare = bareTaskId(epicId, t.taskId);
|
|
74
|
-
|
|
245
|
+
// An approval answers the waiver the operator was shown: a row re-cut at a
|
|
246
|
+
// later plan version needs an approval given at that version or after.
|
|
247
|
+
const approvedAt = approved.get(bare);
|
|
248
|
+
return {
|
|
249
|
+
...t,
|
|
250
|
+
gate: { gateOutcome: gated.has(bare), resultRecorded: recorded.has(bare) },
|
|
251
|
+
waiverApproved: approvedAt !== undefined && (t.planVersion === null || approvedAt >= t.planVersion),
|
|
252
|
+
};
|
|
75
253
|
});
|
|
76
254
|
}
|
|
77
255
|
/**
|
|
@@ -92,6 +270,7 @@ export function summariseEpicConcurrency(waves) {
|
|
|
92
270
|
observed: waves.reduce((max, wave) => Math.max(max, wave.peak), 0),
|
|
93
271
|
},
|
|
94
272
|
unobserved: waves.filter((wave) => wave.verdict === 'unobserved').map((wave) => wave.eventId),
|
|
273
|
+
unlinked: waves.filter((wave) => wave.verdict === 'unlinked').map((wave) => wave.eventId),
|
|
95
274
|
problem: null,
|
|
96
275
|
};
|
|
97
276
|
}
|
|
@@ -101,6 +280,7 @@ const UNREADABLE_CONCURRENCY = (problem) => ({
|
|
|
101
280
|
verdicts: zeroedCounts(WAVE_VERDICTS),
|
|
102
281
|
widest: { declared: 0, observed: 0 },
|
|
103
282
|
unobserved: [],
|
|
283
|
+
unlinked: [],
|
|
104
284
|
problem,
|
|
105
285
|
});
|
|
106
286
|
/**
|
|
@@ -127,8 +307,7 @@ export function readEpicConcurrency(events, epicId) {
|
|
|
127
307
|
* against the commit the branch is on now.
|
|
128
308
|
*/
|
|
129
309
|
function integrationBlockers(epicId, integration) {
|
|
130
|
-
const branch =
|
|
131
|
-
const { check, headSha } = integration;
|
|
310
|
+
const { check, headSha, branch } = integration;
|
|
132
311
|
if (check === null) {
|
|
133
312
|
return [
|
|
134
313
|
`Epic "${epicId}" has no integration-root check on record: no command has ever run against ${branch}. Per-task gates only ever saw a worktree (D-42).`,
|
|
@@ -217,17 +396,63 @@ function integrationBlockers(epicId, integration) {
|
|
|
217
396
|
* measured how wide this epic ran, and it is projected as `null` rather than
|
|
218
397
|
* dropped so it can never be read as "it ran fine". It contributes nothing to
|
|
219
398
|
* `blockers` — see {@link EpicConcurrency}.
|
|
399
|
+
*
|
|
400
|
+
* `effort` is required for the same "no forgotten argument" reason as
|
|
401
|
+
* `integration` and `mcp`: it is `specReviewBlockers`'s only way to know a
|
|
402
|
+
* tier waived an absent closing review (run.md step 13), and a defaulted
|
|
403
|
+
* value here would either manufacture that green for every caller or refuse
|
|
404
|
+
* it for every caller — either way, silently. Positioned before the
|
|
405
|
+
* defaulted `plan`/`quarantined`/`concurrency`/`successors` tail so it reads
|
|
406
|
+
* as required alongside `goalCheck`, not folded into the optional group.
|
|
220
407
|
*/
|
|
221
|
-
export function summarizeEpic(epicId, tasks, findings, integration, mcp, specReview, goalCheck, plan = null, quarantined = [], concurrency = null
|
|
408
|
+
export function summarizeEpic(epicId, tasks, findings, integration, mcp, specReview, goalCheck, effort, plan = null, quarantined = [], concurrency = null,
|
|
409
|
+
/**
|
|
410
|
+
* Old task id -> the successor that superseded it (spec.ts's
|
|
411
|
+
* `taskSuccessors`, folded off every `plan-version-created` event this
|
|
412
|
+
* epic has). Defaults to empty: a caller that never threads it gets
|
|
413
|
+
* exactly today's behavior — every superseded task blocks, same as before
|
|
414
|
+
* this parameter existed.
|
|
415
|
+
*/
|
|
416
|
+
successors = new Map()) {
|
|
222
417
|
const taskSummaries = tasks.map((t) => ({
|
|
223
418
|
taskId: t.taskId,
|
|
224
419
|
taskStatus: t.taskStatus,
|
|
225
420
|
}));
|
|
226
|
-
|
|
421
|
+
// A superseded task is terminal (taskStatus.ts's TERMINAL_TASK_STATUSES)
|
|
422
|
+
// but not terminal-OK by itself — it reads terminal-OK only when the
|
|
423
|
+
// successor that replaced it does, recursively (run.md's "completed/
|
|
424
|
+
// superseded/waived" is this rule, not a second one). A follow-up task whose
|
|
425
|
+
// every finding was waived reads terminal-OK too — clearedFollowUpFindings()
|
|
426
|
+
// — and is collected below so the prompt can explain the row it clears.
|
|
427
|
+
const clearedFollowUps = [];
|
|
428
|
+
const nonTerminal = taskSummaries.filter((t, i) => {
|
|
429
|
+
if (TERMINAL_OK_TASK_STATUSES.has(t.taskStatus))
|
|
430
|
+
return false;
|
|
431
|
+
if (t.taskStatus !== SUPERSEDED_TASK_STATUS) {
|
|
432
|
+
const cleared = clearedFollowUpFindings(epicId, tasks[i], findings);
|
|
433
|
+
if (cleared === null)
|
|
434
|
+
return true;
|
|
435
|
+
clearedFollowUps.push({
|
|
436
|
+
taskId: t.taskId,
|
|
437
|
+
taskStatus: t.taskStatus,
|
|
438
|
+
waivedFindingIds: cleared.map((f) => f.finding_id),
|
|
439
|
+
});
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
const successor = resolveSupersededRow(epicId, t.taskId, tasks, successors);
|
|
443
|
+
return successor === undefined || !TERMINAL_OK_TASK_STATUSES.has(successor.taskStatus);
|
|
444
|
+
});
|
|
227
445
|
// D-138: only tasks claimed done are asked for evidence. One still in flight
|
|
228
446
|
// has not been gated yet and already blocks for not being terminal-OK —
|
|
229
447
|
// repeating it here would make an in-progress task read like a forged one.
|
|
230
|
-
|
|
448
|
+
// A waived task is never gated by definition (D-120): it has its own
|
|
449
|
+
// register, the operator's recorded approval. Without one it is a status
|
|
450
|
+
// typed into a plan, and still blocks.
|
|
451
|
+
const hasGateRun = (t) => t.gate.gateOutcome && t.gate.resultRecorded;
|
|
452
|
+
const ungated = tasks.filter((t) => TERMINAL_OK_TASK_STATUSES.has(t.taskStatus) &&
|
|
453
|
+
t.taskStatus !== WAIVED_TASK_STATUS &&
|
|
454
|
+
!hasGateRun(t));
|
|
455
|
+
const unapprovedWaived = tasks.filter((t) => t.taskStatus === WAIVED_TASK_STATUS && !hasGateRun(t) && !t.waiverApproved);
|
|
231
456
|
const ungatedTasks = ungated.map((t) => ({
|
|
232
457
|
taskId: t.taskId,
|
|
233
458
|
taskStatus: t.taskStatus,
|
|
@@ -301,17 +526,77 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
301
526
|
// silently read as done — null here, not [], is what distinguishes
|
|
302
527
|
// "nothing to wait on" from "waiting on nothing outstanding".
|
|
303
528
|
null
|
|
304
|
-
: wellFormedObligationIds.
|
|
529
|
+
: wellFormedObligationIds.flatMap((id) => {
|
|
305
530
|
const bare = bareTaskId(epicId, id);
|
|
306
531
|
const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === bare);
|
|
307
|
-
|
|
308
|
-
|
|
532
|
+
// An obligation named a task a LATER, separate amendment went on
|
|
533
|
+
// to supersede: the stored amends_task_ids is never rewritten
|
|
534
|
+
// (repairObligation refuses to renegotiate a well-formed
|
|
535
|
+
// obligation), so the id is resolved through its successor here,
|
|
536
|
+
// at evaluation time, instead. Only the LANDED-AT version comes
|
|
537
|
+
// from the successor row; the evidence transition() eventually
|
|
538
|
+
// re-checks (outstandingObligations, findings.ts) bare-compares
|
|
539
|
+
// AmendmentDischarge.taskId against the obligation's own
|
|
540
|
+
// amends_task_ids, so the taskId offered as proof has to stay the
|
|
541
|
+
// originally-named id (`row.taskId`) or that revalidation would
|
|
542
|
+
// never find a match and would refuse the discharge outright —
|
|
543
|
+
// the id is resolved for landing-status purposes only, never for
|
|
544
|
+
// the identity the obligation is matched by. The original row
|
|
545
|
+
// stands in unchanged (byte-identical to before this existed)
|
|
546
|
+
// whenever it was never superseded, or its chain does not
|
|
547
|
+
// resolve.
|
|
548
|
+
const evidenceRow = row !== undefined && row.taskStatus === SUPERSEDED_TASK_STATUS
|
|
549
|
+
? (resolveSupersededRow(epicId, row.taskId, tasks, successors) ?? row)
|
|
550
|
+
: row;
|
|
551
|
+
// Terminal-OK is shared by the discharge test and the reasons
|
|
552
|
+
// below; `landed` itself is unchanged.
|
|
553
|
+
const landedOk = evidenceRow !== undefined && TERMINAL_OK_TASK_STATUSES.has(evidenceRow.taskStatus);
|
|
554
|
+
const landed = landedOk &&
|
|
309
555
|
version !== undefined &&
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (landed &&
|
|
313
|
-
satisfiedBy.push({
|
|
314
|
-
|
|
556
|
+
evidenceRow.planVersion !== null &&
|
|
557
|
+
evidenceRow.planVersion >= version;
|
|
558
|
+
if (landed && evidenceRow.planVersion !== null && row !== undefined)
|
|
559
|
+
satisfiedBy.push({
|
|
560
|
+
taskId: row.taskId,
|
|
561
|
+
planVersion: evidenceRow.planVersion,
|
|
562
|
+
});
|
|
563
|
+
if (landed)
|
|
564
|
+
return [];
|
|
565
|
+
// #251: which ids are outstanding is decided above and nowhere
|
|
566
|
+
// else; this only says why, so the blocker stops calling an id
|
|
567
|
+
// "waiting" when it cannot land as things stand.
|
|
568
|
+
if (row === undefined || evidenceRow === undefined)
|
|
569
|
+
return [{ id, reason: { kind: 'no-row' } }];
|
|
570
|
+
if (row.taskStatus === SUPERSEDED_TASK_STATUS && evidenceRow === row)
|
|
571
|
+
return [{ id, reason: { kind: 'chain-unresolved' } }];
|
|
572
|
+
const via = evidenceRow === row ? {} : { successorId: evidenceRow.taskId };
|
|
573
|
+
const status = evidenceRow.taskStatus;
|
|
574
|
+
if (landedOk && evidenceRow.planVersion === null)
|
|
575
|
+
return [{ id, reason: { kind: 'no-plan-version', ...via, status } }];
|
|
576
|
+
if (landedOk && version === undefined)
|
|
577
|
+
return [{ id, reason: { kind: 'unversioned-amendment', ...via, status } }];
|
|
578
|
+
if (landedOk && evidenceRow.planVersion !== null)
|
|
579
|
+
return [
|
|
580
|
+
{
|
|
581
|
+
id,
|
|
582
|
+
reason: {
|
|
583
|
+
kind: 'below-version',
|
|
584
|
+
...via,
|
|
585
|
+
status,
|
|
586
|
+
landedAt: evidenceRow.planVersion,
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
];
|
|
590
|
+
if (endedNotOk(status))
|
|
591
|
+
return [{ id, reason: { kind: 'ended', ...via, status } }];
|
|
592
|
+
if (evidenceRow !== row)
|
|
593
|
+
return [
|
|
594
|
+
{
|
|
595
|
+
id,
|
|
596
|
+
reason: { kind: 'successor-pending', successorId: evidenceRow.taskId, status },
|
|
597
|
+
},
|
|
598
|
+
];
|
|
599
|
+
return [{ id, reason: { kind: 'pending' } }];
|
|
315
600
|
});
|
|
316
601
|
if (malformedObligations.length === 0 && outstanding !== null && outstanding.length === 0) {
|
|
317
602
|
satisfiedAmendments.push({
|
|
@@ -338,7 +623,7 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
338
623
|
findingBlockers.push(`Finding "${f.finding_id}" is amend-pending but names no task ids to wait on — it can never be discharged this way; check how it entered amend-pending.`);
|
|
339
624
|
}
|
|
340
625
|
else if (outstanding.length > 0) {
|
|
341
|
-
findingBlockers.push(
|
|
626
|
+
findingBlockers.push(describeOutstandingObligations(f.finding_id, version, outstanding));
|
|
342
627
|
}
|
|
343
628
|
}
|
|
344
629
|
const blockers = [
|
|
@@ -348,11 +633,22 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
348
633
|
...(taskSummaries.length === 0
|
|
349
634
|
? [`Epic "${epicId}" has no tasks in the event log — nothing to integrate.`]
|
|
350
635
|
: []),
|
|
351
|
-
...nonTerminal.map((t) =>
|
|
636
|
+
...nonTerminal.map((t) => {
|
|
637
|
+
const row = tasks.find((r) => r.taskId === t.taskId);
|
|
638
|
+
if (row?.origin === FOLLOW_UP_TASK_ORIGIN && t.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
639
|
+
return `Task "${t.taskId}" is a follow-up (origin: escalation) and is not terminal-OK (status: ${t.taskStatus}). Complete it, or close every finding attributed to it — it clears once it owns at least one finding and all of them are fixed, refuted or waived.`;
|
|
640
|
+
if (t.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
641
|
+
return `Task "${t.taskId}" is not terminal-OK (status: ${t.taskStatus}).`;
|
|
642
|
+
const successor = resolveSupersededRow(epicId, t.taskId, tasks, successors);
|
|
643
|
+
return successor === undefined
|
|
644
|
+
? `Task "${t.taskId}" is superseded but has no successor recorded in the log — it cannot be counted terminal-OK.`
|
|
645
|
+
: `Task "${t.taskId}" is superseded by "${successor.taskId}", which is not terminal-OK (status: ${successor.taskStatus}).`;
|
|
646
|
+
}),
|
|
352
647
|
...undispatchedTasks.map((t) => `Task "${t.taskId}" is in plan v${plan?.version} but has no events in the log — nothing records it as dispatched, let alone done (plan status: ${t.taskStatus}).`),
|
|
353
648
|
...ungated.map((t) => t.gate.gateOutcome
|
|
354
649
|
? `Task "${t.taskId}" is recorded ${t.taskStatus} and has a gate-outcome, but no task-result-recorded for it exists in the log. \`gate run\` writes both for the task it grades, so this outcome was written by hand — it is a claim, not a gate.`
|
|
355
|
-
: `Task "${t.taskId}" is recorded ${t.taskStatus} with no gate-outcome in the log at all — nothing gated it. Run \`
|
|
650
|
+
: `Task "${t.taskId}" is recorded ${t.taskStatus} with no gate-outcome in the log at all — nothing gated it. Run \`bs gate run\` for it, or supersede the record.`),
|
|
651
|
+
...unapprovedWaived.map((t) => `Task "${t.taskId}" is waived without operator approval — no gate run backs it and no operator approval is recorded. Record one: \`bs waivers approve-task ${t.taskId} --note <why> --actor operator ...\`, or complete the task.`),
|
|
356
652
|
...findingBlockers,
|
|
357
653
|
// A record the fold could not read is a finding of UNKNOWN status, and
|
|
358
654
|
// the open/closed split above simply does not contain it. Treating it as
|
|
@@ -365,9 +661,16 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
365
661
|
...mcpBlockers(epicId, mcp),
|
|
366
662
|
// The roster is what knows the live plan version, so the review's own
|
|
367
663
|
// plan_version has something to be stale against (D-125).
|
|
368
|
-
...specReviewBlockers(epicId, specReview, plan?.version ?? null),
|
|
664
|
+
...specReviewBlockers(epicId, specReview, plan?.version ?? null, effort),
|
|
369
665
|
...goalCheckBlockers(epicId, goalCheck, plan?.version ?? null, plan?.tasks.map((t) => t.taskId) ?? []),
|
|
370
666
|
];
|
|
667
|
+
// Mirrors specReviewBlockers's own skip condition exactly (spec.ts): the
|
|
668
|
+
// one legal way an absent review casts no blocker. Recomputed here rather
|
|
669
|
+
// than threaded back out of specReviewBlockers because the fact is cheap
|
|
670
|
+
// and the alternative — parsing it back out of a blocker string — is not.
|
|
671
|
+
const closingSpecReviewSkippedByTier = specReview.review === null &&
|
|
672
|
+
effort.closingSpecReview === 'when-plan-amended' &&
|
|
673
|
+
(plan?.version ?? null) === 1;
|
|
371
674
|
return {
|
|
372
675
|
epicId,
|
|
373
676
|
tasks: taskSummaries,
|
|
@@ -378,6 +681,7 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
378
681
|
undispatchedTasks,
|
|
379
682
|
ungatedTasks,
|
|
380
683
|
waivedTasks: taskSummaries.filter((t) => t.taskStatus === WAIVED_TASK_STATUS),
|
|
684
|
+
clearedFollowUps,
|
|
381
685
|
openFindings,
|
|
382
686
|
discretionaryFindings,
|
|
383
687
|
satisfiedAmendments,
|
|
@@ -386,6 +690,7 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
386
690
|
specReview,
|
|
387
691
|
goalCheck,
|
|
388
692
|
concurrency,
|
|
693
|
+
closingSpecReviewSkippedByTier,
|
|
389
694
|
blockers,
|
|
390
695
|
mechanicallyReady: blockers.length === 0,
|
|
391
696
|
};
|
|
@@ -455,8 +760,21 @@ function mcpVerdict(mcp) {
|
|
|
455
760
|
* and the exit code are the refutable part; the output is not.
|
|
456
761
|
*/
|
|
457
762
|
export function epicVerdictJudgeRequest(summary, budget) {
|
|
763
|
+
// Keyed by taskId: a row in here is one clearedFollowUpFindings() cleared,
|
|
764
|
+
// so the roster line below can say so instead of printing the raw `todo`
|
|
765
|
+
// beside `Tasks not yet terminal-OK: 0` with nothing to explain the gap —
|
|
766
|
+
// the exact contradiction both external judges refuted in bs-audit-2.
|
|
767
|
+
const clearedFollowUpsByTaskId = new Map(summary.clearedFollowUps.map((c) => [c.taskId, c]));
|
|
458
768
|
const taskLines = summary.tasks.length > 0
|
|
459
|
-
? summary.tasks
|
|
769
|
+
? summary.tasks
|
|
770
|
+
.map((t) => {
|
|
771
|
+
const cleared = clearedFollowUpsByTaskId.get(t.taskId);
|
|
772
|
+
return cleared === undefined
|
|
773
|
+
? ` ${t.taskId}: ${t.taskStatus}`
|
|
774
|
+
: ` ${t.taskId}: ${t.taskStatus} — follow-up (origin: escalation) cleared by rule: ` +
|
|
775
|
+
`every attributed finding closed — fixed, refuted or waived (${cleared.waivedFindingIds.join(', ')}); counted terminal-OK`;
|
|
776
|
+
})
|
|
777
|
+
.join('\n')
|
|
460
778
|
: ' (no tasks)';
|
|
461
779
|
// The plan's side of the roster, stated separately: these ids have no events
|
|
462
780
|
// at all, so their status is what the plan claims, not what happened (D-126).
|
|
@@ -471,8 +789,7 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
471
789
|
// The assembled branch. `integrationBlockers` above holds the epic on every
|
|
472
790
|
// shape but this one, so by the time a judge runs the check is present,
|
|
473
791
|
// passing, and current — the refutable question left is what it RAN.
|
|
474
|
-
const branch =
|
|
475
|
-
const { check, headSha } = summary.integration;
|
|
792
|
+
const { check, headSha, branch } = summary.integration;
|
|
476
793
|
const integrationLines = check === null
|
|
477
794
|
? [` There is no integration-root check on record for ${branch}.`]
|
|
478
795
|
: [
|
|
@@ -535,6 +852,9 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
535
852
|
` Verdict per wave: ${WAVE_VERDICTS.map((v) => `${v} ${concurrency.verdicts[v]}`).join(', ')}`,
|
|
536
853
|
` Waves with no dispatch on record: ${concurrency.unobserved.length}`,
|
|
537
854
|
...listOr(concurrency.unobserved.map((id) => ` wave ${id}: admitted, and the log holds no dispatch on record for it`), ' (none — every admitted wave has work behind it)'),
|
|
855
|
+
` Waves whose work merged off-lineage (width unmeasured): ${concurrency.unlinked.length}`,
|
|
856
|
+
...listOr(concurrency.unlinked.map((id) => ` wave ${id}: admitted, and its work merged or dispatched off this epic's ` +
|
|
857
|
+
'lineage — real work, but its width cannot be measured from here'), ' (none)'),
|
|
538
858
|
];
|
|
539
859
|
const prompt = [
|
|
540
860
|
'You are an adversarial critic in an automated epic-close gate.',
|
|
@@ -573,17 +893,36 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
573
893
|
' depend on one another has nothing to run side by side, and refuting on',
|
|
574
894
|
' width alone would make your verdict a constant rather than a measurement.',
|
|
575
895
|
' What is refutable here is a wave whose tasks were admitted and nothing',
|
|
576
|
-
' shows them running: that is a declaration with no work behind it.',
|
|
896
|
+
' shows them running: that is a declaration with no work behind it. A wave',
|
|
897
|
+
' whose work merged off-lineage is not that — its work is real, only its',
|
|
898
|
+
' width went unmeasured, and that is not grounds to refute the epic.',
|
|
577
899
|
'',
|
|
578
900
|
'Discretionary closures — decided by a person, not shown by the machine:',
|
|
579
901
|
`Tasks waived rather than completed: ${summary.waivedTasks.length}`,
|
|
580
902
|
...listOr(summary.waivedTasks.map((t) => ` ${t.taskId}`)),
|
|
581
|
-
`
|
|
903
|
+
`Follow-ups cleared by waiving every finding they own: ${summary.clearedFollowUps.length}`,
|
|
904
|
+
...listOr(summary.clearedFollowUps.map((c) => ` ${c.taskId} — waived findings: ${c.waivedFindingIds.join(', ')}`)),
|
|
905
|
+
// bs quorum misread: this line sits right above the satisfiedAmendments
|
|
906
|
+
// line below, and a cross-provider judge read "closed by ... amendment: 0"
|
|
907
|
+
// next to "this close marks them amended: N" as the digest contradicting
|
|
908
|
+
// itself. Both counts were right; this one counts closures a person
|
|
909
|
+
// already made BEFORE this close, the other counts what THIS close is
|
|
910
|
+
// about to transition -- the label has to say which side of "this close"
|
|
911
|
+
// it counts, not just what closed the finding.
|
|
912
|
+
`Findings already closed by waiver or amendment before this close: ${summary.discretionaryFindings.length}`,
|
|
582
913
|
...listOr(summary.discretionaryFindings.map(findingLine)),
|
|
583
|
-
|
|
584
|
-
|
|
914
|
+
// bs #222: this line sits right under "Open findings: 0", and a
|
|
915
|
+
// cross-provider judge read the earlier "Amendments this close will
|
|
916
|
+
// discharge: N" wording as N open blockers -- the loop above already
|
|
917
|
+
// proved every one of these already landed (summarizeEpic keeps a
|
|
918
|
+
// still-outstanding amend-pending finding in openFindings/blockers
|
|
919
|
+
// instead, see the `outstanding.length > 0` branch), so the digest has
|
|
920
|
+
// to say the obligation is a done fact, not a debt: not open, not a
|
|
921
|
+
// blocker, only the status label this close still has to flip.
|
|
922
|
+
`Amend-pending findings already landed, not open, not a blocker — this close marks them amended: ${summary.satisfiedAmendments.length}`,
|
|
923
|
+
...listOr(summary.satisfiedAmendments.map((f) => `${findingLine(f)} — already landed (${f.satisfiedBy
|
|
585
924
|
.map((d) => `${d.taskId} at plan v${d.planVersion}`)
|
|
586
|
-
.join(', ')}` +
|
|
925
|
+
.join(', ')})` +
|
|
587
926
|
// D-21 Part 4: a clean discharge must never read as ordinary when
|
|
588
927
|
// the obligation it rested on was corrected -- the judge is the
|
|
589
928
|
// reader this honesty requirement exists for.
|
|
@@ -621,7 +960,7 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
621
960
|
* gate.ts's DEFAULT_JUDGE_BUDGET; not imported from there since gate.ts doesn't export it and
|
|
622
961
|
* epic.ts must not depend on gate.ts (parallel quorum_triggers hosts, not a hierarchy). */
|
|
623
962
|
const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
|
|
624
|
-
function epicQuorumDecisionPayload(quorum, epicId, finderProvider, ready) {
|
|
963
|
+
function epicQuorumDecisionPayload(quorum, epicId, finderProvider, ready, integrationHeadSha) {
|
|
625
964
|
const gating = quorum.gating;
|
|
626
965
|
return {
|
|
627
966
|
task_id: `${epicId}/${RESERVED_TASK_ID}`,
|
|
@@ -629,6 +968,12 @@ function epicQuorumDecisionPayload(quorum, epicId, finderProvider, ready) {
|
|
|
629
968
|
finding_id: null,
|
|
630
969
|
trigger_reason: 'epic-final-verdict',
|
|
631
970
|
finder_provider: finderProvider,
|
|
971
|
+
// The integration branch head this quorum ran against (issue #270).
|
|
972
|
+
// `closeEpic` reads this back to decide whether a later close can reuse
|
|
973
|
+
// this decision instead of paying for the quorum again. A decision from
|
|
974
|
+
// before this field existed carries no `integration_head` and is never
|
|
975
|
+
// reused (undefined matches no sha).
|
|
976
|
+
integration_head: integrationHeadSha,
|
|
632
977
|
outcome: gating.outcome,
|
|
633
978
|
decision: gating.outcome === 'decided' ? gating.decision : null,
|
|
634
979
|
agreement: gating.outcome === 'decided' ? gating.agreement : null,
|
|
@@ -673,12 +1018,13 @@ function resolvePlanRoster(epicId, planOpts) {
|
|
|
673
1018
|
}
|
|
674
1019
|
}
|
|
675
1020
|
/**
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
1021
|
+
* The read-only half of `runEpicVerdict`: fold the lineage into the one
|
|
1022
|
+
* `EpicSummary` every verdict is computed from. Split out so `closeEpic` can
|
|
1023
|
+
* read the same summary and the same lineage events without re-running
|
|
1024
|
+
* `runEpicVerdict`'s quorum step when it decides to reuse a prior decision
|
|
1025
|
+
* (issue #270).
|
|
680
1026
|
*/
|
|
681
|
-
|
|
1027
|
+
async function computeEpicSummary(input, ctx, opts) {
|
|
682
1028
|
// Lineage (D-119). An epic is not a session: `validateCausalParent`'s own
|
|
683
1029
|
// header calls chaining a fresh session onto a full one "the documented way
|
|
684
1030
|
// to run a large epic", and this verdict read one session. Split the real
|
|
@@ -701,6 +1047,7 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
701
1047
|
const summary = summarizeEpic(input.epicId, tasks, findings, {
|
|
702
1048
|
check: latestIntegrationCheck(events, input.epicId),
|
|
703
1049
|
headSha: input.integrationHeadSha,
|
|
1050
|
+
branch: input.integrationBranch,
|
|
704
1051
|
}, input.mcp,
|
|
705
1052
|
// Folded from the log this call already read, and pinned to the same head
|
|
706
1053
|
// as the integration check: the closing spec review reads the assembled
|
|
@@ -708,21 +1055,44 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
708
1055
|
{
|
|
709
1056
|
review: latestSpecReview(events, input.epicId),
|
|
710
1057
|
headSha: input.integrationHeadSha,
|
|
1058
|
+
branch: input.integrationBranch,
|
|
711
1059
|
},
|
|
712
1060
|
// Same shape, other axis: the recorded check comes from the log this call
|
|
713
1061
|
// already read, and the goal it is measured against comes from the
|
|
714
1062
|
// roadmap the caller resolved — a check is only evidence about the text it
|
|
715
1063
|
// read, so the pair is what makes "current" decidable.
|
|
716
|
-
{ check: latestGoalCheck(events, input.epicId), goal: input.goal }, resolvePlanRoster(input.epicId, input.planOpts ?? {}), skipped,
|
|
1064
|
+
{ check: latestGoalCheck(events, input.epicId), goal: input.goal }, input.effort, resolvePlanRoster(input.epicId, input.planOpts ?? {}), skipped,
|
|
717
1065
|
// Off the same lineage `events` every other fact here came from — the
|
|
718
1066
|
// wave record is in that log already, so measuring width costs this call
|
|
719
1067
|
// no second read and no second command anyone has to remember to type.
|
|
720
|
-
readEpicConcurrency(events, input.epicId)
|
|
1068
|
+
readEpicConcurrency(events, input.epicId),
|
|
1069
|
+
// Same lineage read again, no second I/O: which superseded task ids this
|
|
1070
|
+
// epic's amendments paired with a replacement, so a chain of them can
|
|
1071
|
+
// resolve to whatever is actually carrying the work now.
|
|
1072
|
+
taskSuccessors(events, input.epicId));
|
|
1073
|
+
return { events, summary };
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Operator-invoked verdict for one epic: mechanical readiness first, then
|
|
1077
|
+
* (only when mechanically ready and it isn't free to skip) a cross-provider
|
|
1078
|
+
* quorum on the claim "this epic is ready to open its integration PR". Never
|
|
1079
|
+
* dispatches anything itself — the caller decides what to do with `go`/`hold`.
|
|
1080
|
+
*/
|
|
1081
|
+
export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
1082
|
+
const { summary } = await computeEpicSummary(input, ctx, opts);
|
|
721
1083
|
// Step 1 — mechanical_oracles_first, literally: a deterministic blocker is
|
|
722
1084
|
// final. Zero judge calls, zero events (read-only projection).
|
|
723
1085
|
if (!summary.mechanicallyReady) {
|
|
724
1086
|
return { outcome: 'hold', epicId: input.epicId, summary, reason: 'mechanical-blockers' };
|
|
725
1087
|
}
|
|
1088
|
+
return runEpicQuorumVerdict(input, ctx, opts, summary);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Steps 2-5 of `runEpicVerdict`, pulled out so `closeEpic` can skip straight
|
|
1092
|
+
* to them with an `EpicSummary` it already has (and so it can skip them
|
|
1093
|
+
* entirely when it reuses a prior decision instead, issue #270).
|
|
1094
|
+
*/
|
|
1095
|
+
async function runEpicQuorumVerdict(input, ctx, opts, summary) {
|
|
726
1096
|
const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
|
|
727
1097
|
const providers = enabledExternalProviders(policy);
|
|
728
1098
|
// Step 2 — zero-cost-by-default short-circuit, see module header §4.
|
|
@@ -766,16 +1136,54 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
766
1136
|
: { outcome: 'go', epicId: input.epicId, summary, quorum };
|
|
767
1137
|
}
|
|
768
1138
|
// Step 5 — emit exactly once, for any case that actually ran a quorum.
|
|
769
|
-
await appendEvent({
|
|
1139
|
+
const stored = await appendEvent({
|
|
770
1140
|
session_id: ctx.sessionId,
|
|
771
1141
|
actor: ctx.actor ?? 'system',
|
|
772
1142
|
event_type: 'quorum-decision',
|
|
773
1143
|
task_id: `${input.epicId}/${RESERVED_TASK_ID}`,
|
|
774
1144
|
plan_version: ctx.planVersion,
|
|
775
1145
|
causal_parent: ctx.causalParent,
|
|
776
|
-
payload: epicQuorumDecisionPayload(quorum, input.epicId, nativeProvider, outcome.outcome === 'go'),
|
|
1146
|
+
payload: epicQuorumDecisionPayload(quorum, input.epicId, nativeProvider, outcome.outcome === 'go', input.integrationHeadSha),
|
|
777
1147
|
}, opts);
|
|
778
|
-
return outcome;
|
|
1148
|
+
return { ...outcome, event_id: stored.event_id };
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Issue #270: `epic close` used to re-run `runEpicVerdict` unconditionally,
|
|
1152
|
+
* paying for the external quorum a second time on the same integration head
|
|
1153
|
+
* `epic verdict` just paid for. This looks for a prior `go` decision this
|
|
1154
|
+
* close can stand on instead.
|
|
1155
|
+
*
|
|
1156
|
+
* Reuse requires the latest event in the lineage to BE that decision — i.e.
|
|
1157
|
+
* nothing was appended between the verdict call that wrote it and this close
|
|
1158
|
+
* — rather than independently re-proving every mechanical input the quorum
|
|
1159
|
+
* depended on unchanged. The two are not quite the same (`mcp`/`goal` are
|
|
1160
|
+
* resolved from the roadmap, not the event log), but cheaply reading "did
|
|
1161
|
+
* anything happen since" and refusing reuse the moment it did is the
|
|
1162
|
+
* conservative side of that gap, and this is the acceptable approximation
|
|
1163
|
+
* the task spec calls for when the exact proof is not cheap.
|
|
1164
|
+
*
|
|
1165
|
+
* A decision from before `integration_head` existed carries no such field
|
|
1166
|
+
* and is never reused — `undefined` matches no sha. Same for a `hold`
|
|
1167
|
+
* (`ready: false`): only a `go` is ever stood on without re-asking.
|
|
1168
|
+
*/
|
|
1169
|
+
function findReusableQuorumDecision(events, epicId, currentHeadSha) {
|
|
1170
|
+
if (currentHeadSha === null || events.length === 0)
|
|
1171
|
+
return null;
|
|
1172
|
+
const last = events[events.length - 1];
|
|
1173
|
+
if (last === undefined)
|
|
1174
|
+
return null;
|
|
1175
|
+
if (last.record.event_type !== 'quorum-decision')
|
|
1176
|
+
return null;
|
|
1177
|
+
if (last.record.task_id !== `${epicId}/${RESERVED_TASK_ID}`)
|
|
1178
|
+
return null;
|
|
1179
|
+
const payload = last.record.payload;
|
|
1180
|
+
if (payload.trigger_reason !== 'epic-final-verdict')
|
|
1181
|
+
return null;
|
|
1182
|
+
if (payload.integration_head !== currentHeadSha)
|
|
1183
|
+
return null;
|
|
1184
|
+
if (payload.ready !== true)
|
|
1185
|
+
return null;
|
|
1186
|
+
return { eventId: last.event_id };
|
|
779
1187
|
}
|
|
780
1188
|
/**
|
|
781
1189
|
* D-43/P9-27. Everything above is a read-only probe, and that is the point:
|
|
@@ -907,6 +1315,13 @@ function epicSummaryPayload(summary) {
|
|
|
907
1315
|
reviewed_by: review.reviewedBy,
|
|
908
1316
|
finding_count: review.findingIds.length,
|
|
909
1317
|
},
|
|
1318
|
+
// A skipped review and a clean one are not the same fact (run.md step
|
|
1319
|
+
// 13): spec_review reads identically (null) in both "nothing ran because
|
|
1320
|
+
// nothing was owed" and "nothing ran because none was on record" cases,
|
|
1321
|
+
// so this field is what tells them apart. Always projected, for the same
|
|
1322
|
+
// reason as spec_review itself — a reader must not have to infer "was
|
|
1323
|
+
// this waived by tier" from an absent key.
|
|
1324
|
+
closing_spec_review_skipped_by_tier: summary.closingSpecReviewSkippedByTier,
|
|
910
1325
|
// Projected even at zero, and even as null, for the reason plan_version
|
|
911
1326
|
// gives above. This is the only record that outlives the close, and the
|
|
912
1327
|
// question it answers — did this epic actually run its plan in parallel,
|
|
@@ -921,6 +1336,7 @@ function epicSummaryPayload(summary) {
|
|
|
921
1336
|
verdicts: { ...summary.concurrency.verdicts },
|
|
922
1337
|
widest: { ...summary.concurrency.widest },
|
|
923
1338
|
unobserved: [...summary.concurrency.unobserved],
|
|
1339
|
+
unlinked: [...summary.concurrency.unlinked],
|
|
924
1340
|
problem: summary.concurrency.problem,
|
|
925
1341
|
},
|
|
926
1342
|
mechanically_ready: summary.mechanicallyReady,
|
|
@@ -936,8 +1352,9 @@ export async function closeEpic(input, ctx, opts = {}) {
|
|
|
936
1352
|
// else. The question here is "does THIS session have a log", and only this
|
|
937
1353
|
// session's log answers it; a lineage read would be asking whether some
|
|
938
1354
|
// ancestor exists, which is not what the refusal below says. Nothing is lost
|
|
939
|
-
// — every DECISION this function makes comes from
|
|
940
|
-
//
|
|
1355
|
+
// — every DECISION this function makes comes from computeEpicSummary and
|
|
1356
|
+
// runEpicQuorumVerdict (or a reused quorum decision), and those read the
|
|
1357
|
+
// lineage.
|
|
941
1358
|
const events = await readEvents(ctx.sessionId, opts);
|
|
942
1359
|
if (events.length === 0) {
|
|
943
1360
|
throw new EpicCloseError('epic.unknown-session', `Refusing to close "${input.epicId}" against session "${ctx.sessionId}", which has no event log: the close would be the first line of a log nobody is reading.`, { epicId: input.epicId, sessionId: ctx.sessionId });
|
|
@@ -946,7 +1363,37 @@ export async function closeEpic(input, ctx, opts = {}) {
|
|
|
946
1363
|
if (input.overrideRationale !== undefined && rationale === '') {
|
|
947
1364
|
throw new EpicCloseError('epic.close-refused', `Refusing to close "${input.epicId}" with a blank override rationale: the reason is the whole point of recording the override.`, { epicId: input.epicId });
|
|
948
1365
|
}
|
|
949
|
-
|
|
1366
|
+
// Issue #270: compute the same summary `runEpicVerdict` would, then — only
|
|
1367
|
+
// when it is mechanically ready — look for a `go` quorum-decision already
|
|
1368
|
+
// on record for this exact integration head before paying for a fresh
|
|
1369
|
+
// quorum. A decision this call stands on is referenced by event id in the
|
|
1370
|
+
// `epic-closed` payload below rather than re-run.
|
|
1371
|
+
const { events: lineageEvents, summary: closeSummary } = await computeEpicSummary(input, ctx, opts);
|
|
1372
|
+
let verdict;
|
|
1373
|
+
let reusedQuorumEventId = null;
|
|
1374
|
+
if (!closeSummary.mechanicallyReady) {
|
|
1375
|
+
verdict = {
|
|
1376
|
+
outcome: 'hold',
|
|
1377
|
+
epicId: input.epicId,
|
|
1378
|
+
summary: closeSummary,
|
|
1379
|
+
reason: 'mechanical-blockers',
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
const reusable = findReusableQuorumDecision(lineageEvents, input.epicId, input.integrationHeadSha);
|
|
1384
|
+
if (reusable) {
|
|
1385
|
+
reusedQuorumEventId = reusable.eventId;
|
|
1386
|
+
verdict = {
|
|
1387
|
+
outcome: 'go',
|
|
1388
|
+
epicId: input.epicId,
|
|
1389
|
+
summary: closeSummary,
|
|
1390
|
+
event_id: reusable.eventId,
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
verdict = await runEpicQuorumVerdict(input, ctx, opts, closeSummary);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
950
1397
|
const blockers = verdict.outcome === 'hold' ? verdict.summary.blockers : [];
|
|
951
1398
|
if (verdict.outcome === 'hold' && rationale === '') {
|
|
952
1399
|
throw new EpicCloseError('epic.close-refused', `Refusing to close "${input.epicId}": the verdict is hold (${verdict.reason}). Pass --override-rationale to close over it.\n${blockers.map((b) => ` - ${b}`).join('\n')}`, { epicId: input.epicId, reason: verdict.reason, blockers });
|
|
@@ -988,6 +1435,11 @@ export async function closeEpic(input, ctx, opts = {}) {
|
|
|
988
1435
|
override_rationale: closedBy === 'operator-override' ? rationale : null,
|
|
989
1436
|
blockers,
|
|
990
1437
|
summary: epicSummaryPayload(verdict.summary),
|
|
1438
|
+
// Issue #270: the quorum-decision this close stood on, by event id —
|
|
1439
|
+
// present whether it ran fresh just now or was reused unchanged, null
|
|
1440
|
+
// when no quorum ever ran (e.g. zero-cost default config).
|
|
1441
|
+
quorum_decision_event_id: verdict.event_id ?? null,
|
|
1442
|
+
quorum_decision_reused: reusedQuorumEventId !== null,
|
|
991
1443
|
},
|
|
992
1444
|
}, opts);
|
|
993
1445
|
return {
|