@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.
Files changed (131) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +4 -4
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +34 -27
  17. package/.claude/skills/bs/dispatch.md +100 -50
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +27 -18
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +181 -49
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +109 -53
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +48 -14
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +125 -17
  35. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  36. package/factory/orchestrator/dist/budgets.js +302 -84
  37. package/factory/orchestrator/dist/claims.js +9 -19
  38. package/factory/orchestrator/dist/cli.js +790 -100
  39. package/factory/orchestrator/dist/cliName.js +27 -0
  40. package/factory/orchestrator/dist/crosscheck.js +136 -8
  41. package/factory/orchestrator/dist/daemon.js +154 -36
  42. package/factory/orchestrator/dist/db/projector.js +278 -29
  43. package/factory/orchestrator/dist/db/queries.js +1831 -94
  44. package/factory/orchestrator/dist/db/schema.js +57 -4
  45. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  46. package/factory/orchestrator/dist/delegation.js +86 -19
  47. package/factory/orchestrator/dist/diffstat.js +15 -8
  48. package/factory/orchestrator/dist/dispatchLint.js +13 -37
  49. package/factory/orchestrator/dist/effort.js +14 -2
  50. package/factory/orchestrator/dist/effortTiers.js +13 -0
  51. package/factory/orchestrator/dist/env.js +26 -0
  52. package/factory/orchestrator/dist/epic.js +323 -38
  53. package/factory/orchestrator/dist/epicBudget.js +93 -0
  54. package/factory/orchestrator/dist/epicWidth.js +22 -5
  55. package/factory/orchestrator/dist/errorIssues.js +10 -4
  56. package/factory/orchestrator/dist/escalation.js +2 -2
  57. package/factory/orchestrator/dist/events.js +105 -3
  58. package/factory/orchestrator/dist/feedback.js +169 -0
  59. package/factory/orchestrator/dist/findingContext.js +26 -0
  60. package/factory/orchestrator/dist/findings.js +83 -12
  61. package/factory/orchestrator/dist/gate.js +120 -10
  62. package/factory/orchestrator/dist/goalCheck.js +2 -2
  63. package/factory/orchestrator/dist/harness.js +25 -17
  64. package/factory/orchestrator/dist/hookDecision.js +92 -3
  65. package/factory/orchestrator/dist/integration.js +9 -7
  66. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  67. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  68. package/factory/orchestrator/dist/judgeStopHook.js +90 -32
  69. package/factory/orchestrator/dist/judges.js +419 -21
  70. package/factory/orchestrator/dist/lessons.js +190 -11
  71. package/factory/orchestrator/dist/mcp.js +6 -6
  72. package/factory/orchestrator/dist/paths.js +10 -7
  73. package/factory/orchestrator/dist/plan.js +269 -33
  74. package/factory/orchestrator/dist/planQuorum.js +5 -2
  75. package/factory/orchestrator/dist/policy.js +1275 -30
  76. package/factory/orchestrator/dist/projectName.js +25 -0
  77. package/factory/orchestrator/dist/promptCapture.js +306 -0
  78. package/factory/orchestrator/dist/promptHook.js +33 -0
  79. package/factory/orchestrator/dist/promptImport.js +281 -0
  80. package/factory/orchestrator/dist/prompts.js +13 -0
  81. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  82. package/factory/orchestrator/dist/providers/types.js +1 -1
  83. package/factory/orchestrator/dist/queue.js +4 -4
  84. package/factory/orchestrator/dist/quorum.js +18 -5
  85. package/factory/orchestrator/dist/results.js +25 -7
  86. package/factory/orchestrator/dist/roadmap.js +10 -5
  87. package/factory/orchestrator/dist/run-cli.js +15 -10
  88. package/factory/orchestrator/dist/runner.js +2 -2
  89. package/factory/orchestrator/dist/schemas.js +56 -8
  90. package/factory/orchestrator/dist/severity.js +8 -4
  91. package/factory/orchestrator/dist/spec.js +177 -13
  92. package/factory/orchestrator/dist/specChange.js +13 -9
  93. package/factory/orchestrator/dist/stack.js +6 -6
  94. package/factory/orchestrator/dist/storeRoot.js +90 -0
  95. package/factory/orchestrator/dist/symbols.js +261 -6
  96. package/factory/orchestrator/dist/taskEvents.js +179 -27
  97. package/factory/orchestrator/dist/taskStatus.js +14 -0
  98. package/factory/orchestrator/dist/testerAudit.js +31 -12
  99. package/factory/orchestrator/dist/testgate.js +16 -13
  100. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  101. package/factory/orchestrator/dist/usage.js +97 -40
  102. package/factory/orchestrator/dist/waivers.js +39 -5
  103. package/factory/orchestrator/dist/waveBudget.js +2 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  105. package/factory/orchestrator/dist/workroot.js +1 -1
  106. package/factory/orchestrator/dist/worktree.js +61 -19
  107. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  108. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  109. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  110. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  111. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  112. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  113. package/factory/policies/budgets.yml +123 -33
  114. package/factory/policies/crosscheck.yml +51 -13
  115. package/factory/policies/delegation.yml +3 -3
  116. package/factory/policies/effort.yml +18 -10
  117. package/factory/policies/guardrails.yml +8 -6
  118. package/factory/policies/harness.yml +5 -5
  119. package/factory/policies/lessons.md +53 -2
  120. package/factory/policies/scheduler.yml +4 -4
  121. package/factory/policies/sensitive-paths.yml +1 -1
  122. package/factory/policies/severity.yml +13 -1
  123. package/factory/policies/stack.yml +6 -6
  124. package/factory/policies/taxonomy.yml +9 -3
  125. package/factory/policies/worktree.yml +15 -9
  126. package/factory/specs/roadmap.md +7 -7
  127. package/factory/specs/schema/event.schema.json +8 -1
  128. package/factory/specs/schema/result.schema.json +21 -8
  129. package/factory/specs/schema/task-spec.schema.json +12 -0
  130. package/package.json +16 -12
  131. 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 `waived`: one still open, or closed any other
114
- * way (a fix verified by a gate the task never ran), still blocks.
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 && owned.every((f) => f.finding_status === WAIVED_FINDING_STATUS)
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
- return { ...t, gate: { gateOutcome: gated.has(bare), resultRecorded: recorded.has(bare) } };
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 = `smith/${epicId}/${RESERVED_TASK_ID}`;
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
- const ungated = tasks.filter((t) => TERMINAL_OK_TASK_STATUSES.has(t.taskStatus) && !(t.gate.gateOutcome && t.gate.resultRecorded));
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.filter((id) => {
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
- const landed = evidenceRow !== undefined &&
442
- TERMINAL_OK_TASK_STATUSES.has(evidenceRow.taskStatus) &&
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
- return !landed;
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(`Finding "${f.finding_id}" is amend-pending on plan v${version}: waiting on ${outstanding.join(', ')} to land terminal-OK at v${version} or later.`);
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 waive every finding attributed to it — it clears once it owns at least one finding and all of them are waived.`;
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 \`smith gate run\` for it, or supersede the record.`),
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 = `smith/${summary.epicId}/${RESERVED_TASK_ID}`;
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
- `Findings closed by waiver or amendment: ${summary.discretionaryFindings.length}`,
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
- `Amendments this close will discharge: ${summary.satisfiedAmendments.length}`,
747
- ...listOr(summary.satisfiedAmendments.map((f) => `${findingLine(f)} — discharged by ${f.satisfiedBy
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
- * Operator-invoked verdict for one epic: mechanical readiness first, then
840
- * (only when mechanically ready and it isn't free to skip) a cross-provider
841
- * quorum on the claim "this epic is ready to open its integration PR". Never
842
- * dispatches anything itself — the caller decides what to do with `go`/`hold`.
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
- export async function runEpicVerdict(input, ctx, opts = {}) {
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 runEpicVerdict, and that
1107
- // reads the lineage.
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
- const verdict = await runEpicVerdict(input, ctx, opts);
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 {