@juzser/blacksmith 0.3.0 → 0.5.0

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