@juzser/blacksmith 0.4.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} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- 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} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -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} +4 -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 +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- 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 +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- 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 +109 -53
- 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 +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- 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 +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- 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 +13 -37
- 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 +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -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/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 +4 -4
- 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/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- 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 +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- 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 +7 -7
- 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';
|
|
@@ -10,7 +11,8 @@ 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
13
|
import { latestSpecReview, specReviewBlockers, taskSuccessors, } from './spec.js';
|
|
13
|
-
import { TERMINAL_OK_TASK_STATUSES } from './taskStatus.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
|
/**
|
|
@@ -88,6 +90,70 @@ function resolveSupersededRow(epicId, taskId, tasks, successors) {
|
|
|
88
90
|
currentId = nextBare;
|
|
89
91
|
}
|
|
90
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
|
+
}
|
|
91
157
|
/**
|
|
92
158
|
* The `origin` taskEvents.ts's `emitFollowUpTask` stamps on a follow-up task:
|
|
93
159
|
* one minted for a finding no open task could own, rather than one a plan cut.
|
|
@@ -95,6 +161,17 @@ function resolveSupersededRow(epicId, taskId, tasks, successors) {
|
|
|
95
161
|
const FOLLOW_UP_TASK_ORIGIN = 'escalation';
|
|
96
162
|
/** The finding status that is the operator's decision not to fix (D-120). */
|
|
97
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
|
+
]);
|
|
98
175
|
/**
|
|
99
176
|
* Whether a follow-up task's whole reason to exist has been waived away. A
|
|
100
177
|
* follow-up is minted `todo` to own findings, and no event ever moves it to
|
|
@@ -110,8 +187,9 @@ const WAIVED_FINDING_STATUS = 'waived';
|
|
|
110
187
|
* discharge;
|
|
111
188
|
* - at least one attributed finding: a follow-up that owns nothing is not
|
|
112
189
|
* "all waived", it is unexplained, and absence must not vote yes (D-126);
|
|
113
|
-
* - every attributed finding
|
|
114
|
-
*
|
|
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.
|
|
115
193
|
*
|
|
116
194
|
* Attribution is `finding.task_id`. A finding routed to a follow-up is raised
|
|
117
195
|
* under it (attribution.ts's `reattributeFinding` re-mints the id before the
|
|
@@ -127,7 +205,8 @@ function clearedFollowUpFindings(epicId, row, findings) {
|
|
|
127
205
|
return null;
|
|
128
206
|
const bare = bareTaskId(epicId, row.taskId);
|
|
129
207
|
const owned = findings.filter((f) => bareTaskId(epicId, f.task_id) === bare);
|
|
130
|
-
return owned.length > 0 &&
|
|
208
|
+
return owned.length > 0 &&
|
|
209
|
+
owned.every((f) => FOLLOW_UP_CLEARING_FINDING_STATUSES.has(f.finding_status))
|
|
131
210
|
? owned
|
|
132
211
|
: null;
|
|
133
212
|
}
|
|
@@ -145,6 +224,8 @@ const TASK_RESULT_EVENT = 'task-result-recorded';
|
|
|
145
224
|
export function withGateEvidence(tasks, events, epicId) {
|
|
146
225
|
const gated = new Set();
|
|
147
226
|
const recorded = new Set();
|
|
227
|
+
// Newest plan version each task's operator approval was given under.
|
|
228
|
+
const approved = new Map();
|
|
148
229
|
for (const { record } of events) {
|
|
149
230
|
if (!record.task_id)
|
|
150
231
|
continue;
|
|
@@ -152,10 +233,23 @@ export function withGateEvidence(tasks, events, epicId) {
|
|
|
152
233
|
gated.add(bareTaskId(epicId, record.task_id));
|
|
153
234
|
else if (record.event_type === TASK_RESULT_EVENT)
|
|
154
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
|
+
}
|
|
155
242
|
}
|
|
156
243
|
return tasks.map((t) => {
|
|
157
244
|
const bare = bareTaskId(epicId, t.taskId);
|
|
158
|
-
|
|
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
|
+
};
|
|
159
253
|
});
|
|
160
254
|
}
|
|
161
255
|
/**
|
|
@@ -176,6 +270,7 @@ export function summariseEpicConcurrency(waves) {
|
|
|
176
270
|
observed: waves.reduce((max, wave) => Math.max(max, wave.peak), 0),
|
|
177
271
|
},
|
|
178
272
|
unobserved: waves.filter((wave) => wave.verdict === 'unobserved').map((wave) => wave.eventId),
|
|
273
|
+
unlinked: waves.filter((wave) => wave.verdict === 'unlinked').map((wave) => wave.eventId),
|
|
179
274
|
problem: null,
|
|
180
275
|
};
|
|
181
276
|
}
|
|
@@ -185,6 +280,7 @@ const UNREADABLE_CONCURRENCY = (problem) => ({
|
|
|
185
280
|
verdicts: zeroedCounts(WAVE_VERDICTS),
|
|
186
281
|
widest: { declared: 0, observed: 0 },
|
|
187
282
|
unobserved: [],
|
|
283
|
+
unlinked: [],
|
|
188
284
|
problem,
|
|
189
285
|
});
|
|
190
286
|
/**
|
|
@@ -211,8 +307,7 @@ export function readEpicConcurrency(events, epicId) {
|
|
|
211
307
|
* against the commit the branch is on now.
|
|
212
308
|
*/
|
|
213
309
|
function integrationBlockers(epicId, integration) {
|
|
214
|
-
const branch =
|
|
215
|
-
const { check, headSha } = integration;
|
|
310
|
+
const { check, headSha, branch } = integration;
|
|
216
311
|
if (check === null) {
|
|
217
312
|
return [
|
|
218
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).`,
|
|
@@ -301,8 +396,16 @@ function integrationBlockers(epicId, integration) {
|
|
|
301
396
|
* measured how wide this epic ran, and it is projected as `null` rather than
|
|
302
397
|
* dropped so it can never be read as "it ran fine". It contributes nothing to
|
|
303
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.
|
|
304
407
|
*/
|
|
305
|
-
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,
|
|
306
409
|
/**
|
|
307
410
|
* Old task id -> the successor that superseded it (spec.ts's
|
|
308
411
|
* `taskSuccessors`, folded off every `plan-version-created` event this
|
|
@@ -342,7 +445,14 @@ successors = new Map()) {
|
|
|
342
445
|
// D-138: only tasks claimed done are asked for evidence. One still in flight
|
|
343
446
|
// has not been gated yet and already blocks for not being terminal-OK —
|
|
344
447
|
// repeating it here would make an in-progress task read like a forged one.
|
|
345
|
-
|
|
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);
|
|
346
456
|
const ungatedTasks = ungated.map((t) => ({
|
|
347
457
|
taskId: t.taskId,
|
|
348
458
|
taskStatus: t.taskStatus,
|
|
@@ -416,7 +526,7 @@ successors = new Map()) {
|
|
|
416
526
|
// silently read as done — null here, not [], is what distinguishes
|
|
417
527
|
// "nothing to wait on" from "waiting on nothing outstanding".
|
|
418
528
|
null
|
|
419
|
-
: wellFormedObligationIds.
|
|
529
|
+
: wellFormedObligationIds.flatMap((id) => {
|
|
420
530
|
const bare = bareTaskId(epicId, id);
|
|
421
531
|
const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === bare);
|
|
422
532
|
// An obligation named a task a LATER, separate amendment went on
|
|
@@ -438,8 +548,10 @@ successors = new Map()) {
|
|
|
438
548
|
const evidenceRow = row !== undefined && row.taskStatus === SUPERSEDED_TASK_STATUS
|
|
439
549
|
? (resolveSupersededRow(epicId, row.taskId, tasks, successors) ?? row)
|
|
440
550
|
: row;
|
|
441
|
-
|
|
442
|
-
|
|
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 &&
|
|
443
555
|
version !== undefined &&
|
|
444
556
|
evidenceRow.planVersion !== null &&
|
|
445
557
|
evidenceRow.planVersion >= version;
|
|
@@ -448,7 +560,43 @@ successors = new Map()) {
|
|
|
448
560
|
taskId: row.taskId,
|
|
449
561
|
planVersion: evidenceRow.planVersion,
|
|
450
562
|
});
|
|
451
|
-
|
|
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' } }];
|
|
452
600
|
});
|
|
453
601
|
if (malformedObligations.length === 0 && outstanding !== null && outstanding.length === 0) {
|
|
454
602
|
satisfiedAmendments.push({
|
|
@@ -475,7 +623,7 @@ successors = new Map()) {
|
|
|
475
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.`);
|
|
476
624
|
}
|
|
477
625
|
else if (outstanding.length > 0) {
|
|
478
|
-
findingBlockers.push(
|
|
626
|
+
findingBlockers.push(describeOutstandingObligations(f.finding_id, version, outstanding));
|
|
479
627
|
}
|
|
480
628
|
}
|
|
481
629
|
const blockers = [
|
|
@@ -488,7 +636,7 @@ successors = new Map()) {
|
|
|
488
636
|
...nonTerminal.map((t) => {
|
|
489
637
|
const row = tasks.find((r) => r.taskId === t.taskId);
|
|
490
638
|
if (row?.origin === FOLLOW_UP_TASK_ORIGIN && t.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
491
|
-
return `Task "${t.taskId}" is a follow-up (origin: escalation) and is not terminal-OK (status: ${t.taskStatus}). Complete it, or
|
|
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.`;
|
|
492
640
|
if (t.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
493
641
|
return `Task "${t.taskId}" is not terminal-OK (status: ${t.taskStatus}).`;
|
|
494
642
|
const successor = resolveSupersededRow(epicId, t.taskId, tasks, successors);
|
|
@@ -499,7 +647,8 @@ successors = new Map()) {
|
|
|
499
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}).`),
|
|
500
648
|
...ungated.map((t) => t.gate.gateOutcome
|
|
501
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.`
|
|
502
|
-
: `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.`),
|
|
503
652
|
...findingBlockers,
|
|
504
653
|
// A record the fold could not read is a finding of UNKNOWN status, and
|
|
505
654
|
// the open/closed split above simply does not contain it. Treating it as
|
|
@@ -512,9 +661,16 @@ successors = new Map()) {
|
|
|
512
661
|
...mcpBlockers(epicId, mcp),
|
|
513
662
|
// The roster is what knows the live plan version, so the review's own
|
|
514
663
|
// plan_version has something to be stale against (D-125).
|
|
515
|
-
...specReviewBlockers(epicId, specReview, plan?.version ?? null),
|
|
664
|
+
...specReviewBlockers(epicId, specReview, plan?.version ?? null, effort),
|
|
516
665
|
...goalCheckBlockers(epicId, goalCheck, plan?.version ?? null, plan?.tasks.map((t) => t.taskId) ?? []),
|
|
517
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;
|
|
518
674
|
return {
|
|
519
675
|
epicId,
|
|
520
676
|
tasks: taskSummaries,
|
|
@@ -534,6 +690,7 @@ successors = new Map()) {
|
|
|
534
690
|
specReview,
|
|
535
691
|
goalCheck,
|
|
536
692
|
concurrency,
|
|
693
|
+
closingSpecReviewSkippedByTier,
|
|
537
694
|
blockers,
|
|
538
695
|
mechanicallyReady: blockers.length === 0,
|
|
539
696
|
};
|
|
@@ -615,7 +772,7 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
615
772
|
return cleared === undefined
|
|
616
773
|
? ` ${t.taskId}: ${t.taskStatus}`
|
|
617
774
|
: ` ${t.taskId}: ${t.taskStatus} — follow-up (origin: escalation) cleared by rule: ` +
|
|
618
|
-
`every attributed finding waived (${cleared.waivedFindingIds.join(', ')}); counted terminal-OK`;
|
|
775
|
+
`every attributed finding closed — fixed, refuted or waived (${cleared.waivedFindingIds.join(', ')}); counted terminal-OK`;
|
|
619
776
|
})
|
|
620
777
|
.join('\n')
|
|
621
778
|
: ' (no tasks)';
|
|
@@ -632,8 +789,7 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
632
789
|
// The assembled branch. `integrationBlockers` above holds the epic on every
|
|
633
790
|
// shape but this one, so by the time a judge runs the check is present,
|
|
634
791
|
// passing, and current — the refutable question left is what it RAN.
|
|
635
|
-
const branch =
|
|
636
|
-
const { check, headSha } = summary.integration;
|
|
792
|
+
const { check, headSha, branch } = summary.integration;
|
|
637
793
|
const integrationLines = check === null
|
|
638
794
|
? [` There is no integration-root check on record for ${branch}.`]
|
|
639
795
|
: [
|
|
@@ -696,6 +852,9 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
696
852
|
` Verdict per wave: ${WAVE_VERDICTS.map((v) => `${v} ${concurrency.verdicts[v]}`).join(', ')}`,
|
|
697
853
|
` Waves with no dispatch on record: ${concurrency.unobserved.length}`,
|
|
698
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)'),
|
|
699
858
|
];
|
|
700
859
|
const prompt = [
|
|
701
860
|
'You are an adversarial critic in an automated epic-close gate.',
|
|
@@ -734,19 +893,36 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
734
893
|
' depend on one another has nothing to run side by side, and refuting on',
|
|
735
894
|
' width alone would make your verdict a constant rather than a measurement.',
|
|
736
895
|
' What is refutable here is a wave whose tasks were admitted and nothing',
|
|
737
|
-
' 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.',
|
|
738
899
|
'',
|
|
739
900
|
'Discretionary closures — decided by a person, not shown by the machine:',
|
|
740
901
|
`Tasks waived rather than completed: ${summary.waivedTasks.length}`,
|
|
741
902
|
...listOr(summary.waivedTasks.map((t) => ` ${t.taskId}`)),
|
|
742
903
|
`Follow-ups cleared by waiving every finding they own: ${summary.clearedFollowUps.length}`,
|
|
743
904
|
...listOr(summary.clearedFollowUps.map((c) => ` ${c.taskId} — waived findings: ${c.waivedFindingIds.join(', ')}`)),
|
|
744
|
-
|
|
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}`,
|
|
745
913
|
...listOr(summary.discretionaryFindings.map(findingLine)),
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
748
924
|
.map((d) => `${d.taskId} at plan v${d.planVersion}`)
|
|
749
|
-
.join(', ')}` +
|
|
925
|
+
.join(', ')})` +
|
|
750
926
|
// D-21 Part 4: a clean discharge must never read as ordinary when
|
|
751
927
|
// the obligation it rested on was corrected -- the judge is the
|
|
752
928
|
// reader this honesty requirement exists for.
|
|
@@ -784,7 +960,7 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
784
960
|
* gate.ts's DEFAULT_JUDGE_BUDGET; not imported from there since gate.ts doesn't export it and
|
|
785
961
|
* epic.ts must not depend on gate.ts (parallel quorum_triggers hosts, not a hierarchy). */
|
|
786
962
|
const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
|
|
787
|
-
function epicQuorumDecisionPayload(quorum, epicId, finderProvider, ready) {
|
|
963
|
+
function epicQuorumDecisionPayload(quorum, epicId, finderProvider, ready, integrationHeadSha) {
|
|
788
964
|
const gating = quorum.gating;
|
|
789
965
|
return {
|
|
790
966
|
task_id: `${epicId}/${RESERVED_TASK_ID}`,
|
|
@@ -792,6 +968,12 @@ function epicQuorumDecisionPayload(quorum, epicId, finderProvider, ready) {
|
|
|
792
968
|
finding_id: null,
|
|
793
969
|
trigger_reason: 'epic-final-verdict',
|
|
794
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,
|
|
795
977
|
outcome: gating.outcome,
|
|
796
978
|
decision: gating.outcome === 'decided' ? gating.decision : null,
|
|
797
979
|
agreement: gating.outcome === 'decided' ? gating.agreement : null,
|
|
@@ -836,12 +1018,13 @@ function resolvePlanRoster(epicId, planOpts) {
|
|
|
836
1018
|
}
|
|
837
1019
|
}
|
|
838
1020
|
/**
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
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).
|
|
843
1026
|
*/
|
|
844
|
-
|
|
1027
|
+
async function computeEpicSummary(input, ctx, opts) {
|
|
845
1028
|
// Lineage (D-119). An epic is not a session: `validateCausalParent`'s own
|
|
846
1029
|
// header calls chaining a fresh session onto a full one "the documented way
|
|
847
1030
|
// to run a large epic", and this verdict read one session. Split the real
|
|
@@ -864,6 +1047,7 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
864
1047
|
const summary = summarizeEpic(input.epicId, tasks, findings, {
|
|
865
1048
|
check: latestIntegrationCheck(events, input.epicId),
|
|
866
1049
|
headSha: input.integrationHeadSha,
|
|
1050
|
+
branch: input.integrationBranch,
|
|
867
1051
|
}, input.mcp,
|
|
868
1052
|
// Folded from the log this call already read, and pinned to the same head
|
|
869
1053
|
// as the integration check: the closing spec review reads the assembled
|
|
@@ -871,12 +1055,13 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
871
1055
|
{
|
|
872
1056
|
review: latestSpecReview(events, input.epicId),
|
|
873
1057
|
headSha: input.integrationHeadSha,
|
|
1058
|
+
branch: input.integrationBranch,
|
|
874
1059
|
},
|
|
875
1060
|
// Same shape, other axis: the recorded check comes from the log this call
|
|
876
1061
|
// already read, and the goal it is measured against comes from the
|
|
877
1062
|
// roadmap the caller resolved — a check is only evidence about the text it
|
|
878
1063
|
// read, so the pair is what makes "current" decidable.
|
|
879
|
-
{ 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,
|
|
880
1065
|
// Off the same lineage `events` every other fact here came from — the
|
|
881
1066
|
// wave record is in that log already, so measuring width costs this call
|
|
882
1067
|
// no second read and no second command anyone has to remember to type.
|
|
@@ -885,11 +1070,29 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
885
1070
|
// epic's amendments paired with a replacement, so a chain of them can
|
|
886
1071
|
// resolve to whatever is actually carrying the work now.
|
|
887
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);
|
|
888
1083
|
// Step 1 — mechanical_oracles_first, literally: a deterministic blocker is
|
|
889
1084
|
// final. Zero judge calls, zero events (read-only projection).
|
|
890
1085
|
if (!summary.mechanicallyReady) {
|
|
891
1086
|
return { outcome: 'hold', epicId: input.epicId, summary, reason: 'mechanical-blockers' };
|
|
892
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) {
|
|
893
1096
|
const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
|
|
894
1097
|
const providers = enabledExternalProviders(policy);
|
|
895
1098
|
// Step 2 — zero-cost-by-default short-circuit, see module header §4.
|
|
@@ -933,16 +1136,54 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
933
1136
|
: { outcome: 'go', epicId: input.epicId, summary, quorum };
|
|
934
1137
|
}
|
|
935
1138
|
// Step 5 — emit exactly once, for any case that actually ran a quorum.
|
|
936
|
-
await appendEvent({
|
|
1139
|
+
const stored = await appendEvent({
|
|
937
1140
|
session_id: ctx.sessionId,
|
|
938
1141
|
actor: ctx.actor ?? 'system',
|
|
939
1142
|
event_type: 'quorum-decision',
|
|
940
1143
|
task_id: `${input.epicId}/${RESERVED_TASK_ID}`,
|
|
941
1144
|
plan_version: ctx.planVersion,
|
|
942
1145
|
causal_parent: ctx.causalParent,
|
|
943
|
-
payload: epicQuorumDecisionPayload(quorum, input.epicId, nativeProvider, outcome.outcome === 'go'),
|
|
1146
|
+
payload: epicQuorumDecisionPayload(quorum, input.epicId, nativeProvider, outcome.outcome === 'go', input.integrationHeadSha),
|
|
944
1147
|
}, opts);
|
|
945
|
-
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 };
|
|
946
1187
|
}
|
|
947
1188
|
/**
|
|
948
1189
|
* D-43/P9-27. Everything above is a read-only probe, and that is the point:
|
|
@@ -1074,6 +1315,13 @@ function epicSummaryPayload(summary) {
|
|
|
1074
1315
|
reviewed_by: review.reviewedBy,
|
|
1075
1316
|
finding_count: review.findingIds.length,
|
|
1076
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,
|
|
1077
1325
|
// Projected even at zero, and even as null, for the reason plan_version
|
|
1078
1326
|
// gives above. This is the only record that outlives the close, and the
|
|
1079
1327
|
// question it answers — did this epic actually run its plan in parallel,
|
|
@@ -1088,6 +1336,7 @@ function epicSummaryPayload(summary) {
|
|
|
1088
1336
|
verdicts: { ...summary.concurrency.verdicts },
|
|
1089
1337
|
widest: { ...summary.concurrency.widest },
|
|
1090
1338
|
unobserved: [...summary.concurrency.unobserved],
|
|
1339
|
+
unlinked: [...summary.concurrency.unlinked],
|
|
1091
1340
|
problem: summary.concurrency.problem,
|
|
1092
1341
|
},
|
|
1093
1342
|
mechanically_ready: summary.mechanicallyReady,
|
|
@@ -1103,8 +1352,9 @@ export async function closeEpic(input, ctx, opts = {}) {
|
|
|
1103
1352
|
// else. The question here is "does THIS session have a log", and only this
|
|
1104
1353
|
// session's log answers it; a lineage read would be asking whether some
|
|
1105
1354
|
// ancestor exists, which is not what the refusal below says. Nothing is lost
|
|
1106
|
-
// — every DECISION this function makes comes from
|
|
1107
|
-
//
|
|
1355
|
+
// — every DECISION this function makes comes from computeEpicSummary and
|
|
1356
|
+
// runEpicQuorumVerdict (or a reused quorum decision), and those read the
|
|
1357
|
+
// lineage.
|
|
1108
1358
|
const events = await readEvents(ctx.sessionId, opts);
|
|
1109
1359
|
if (events.length === 0) {
|
|
1110
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 });
|
|
@@ -1113,7 +1363,37 @@ export async function closeEpic(input, ctx, opts = {}) {
|
|
|
1113
1363
|
if (input.overrideRationale !== undefined && rationale === '') {
|
|
1114
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 });
|
|
1115
1365
|
}
|
|
1116
|
-
|
|
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
|
+
}
|
|
1117
1397
|
const blockers = verdict.outcome === 'hold' ? verdict.summary.blockers : [];
|
|
1118
1398
|
if (verdict.outcome === 'hold' && rationale === '') {
|
|
1119
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 });
|
|
@@ -1155,6 +1435,11 @@ export async function closeEpic(input, ctx, opts = {}) {
|
|
|
1155
1435
|
override_rationale: closedBy === 'operator-override' ? rationale : null,
|
|
1156
1436
|
blockers,
|
|
1157
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,
|
|
1158
1443
|
},
|
|
1159
1444
|
}, opts);
|
|
1160
1445
|
return {
|