@juzser/blacksmith 0.1.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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,1018 @@
1
+ import { EPIC_CLOSED_EVENT_TYPE } from './agents-registry.js';
2
+ import { loadCrosscheckPolicy } from './crosscheck.js';
3
+ import { foldTasks } from './db/projector.js';
4
+ import { SmithError } from './errors.js';
5
+ import { appendEvent, readEvents, readLineageEvents, } from './events.js';
6
+ import { AMEND_PENDING_STATUS, AMENDED_STATUS, describeMalformedTaskId, foldFindingsDetailed, isNonEmptyString, OPEN_FINDING_STATUSES, transition, } from './findings.js';
7
+ import { goalCheckBlockers, latestGoalCheck, } from './goalCheck.js';
8
+ import { latestIntegrationCheck } from './integration.js';
9
+ import { mcpBlockers } from './mcp.js';
10
+ import { bareTaskId, latestPlanVersion, livePlanTasks, loadPlan } from './plan.js';
11
+ import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
12
+ import { latestSpecReview, specReviewBlockers } from './spec.js';
13
+ import { auditWaveConcurrency } from './waveConcurrency.js';
14
+ import { RESERVED_TASK_ID } from './worktree.js';
15
+ /**
16
+ * Third quorum_triggers host (factory/policies/crosscheck.yml): "epic-level
17
+ * final verdict, before the integration PR opens". Same trigger-agnostic
18
+ * quorum.ts engine gate.ts already uses for blocking-finding/same-mistake —
19
+ * this module is a CALLER, not a variant of it.
20
+ *
21
+ * mechanical_oracles_first, applied literally: summarizeEpic() below is the
22
+ * deterministic oracle and runs first; a mechanically-not-ready epic never
23
+ * reaches a judge (§ runEpicVerdict step 1).
24
+ *
25
+ * §4 consequence of asymmetric_roles.finder_ne_critic (crosscheck.yml): the
26
+ * readiness claim here is the native provider's own ("this epic is done"),
27
+ * so native is always the finder and is always excluded from the gating
28
+ * pool, exactly like any other claim. With quorum_rule.min_providers: 2:
29
+ * - 0 enabled externals: computeQuorum() would escalate
30
+ * insufficient-providers on EVERY mechanically-ready epic, since the
31
+ * gating pool is empty by construction. Calling runQuorumCase() at all
32
+ * in that case would be pure overhead for a foregone conclusion, so
33
+ * step 2 below short-circuits straight to `go` — this is the same
34
+ * "shipped default costs nothing" rule as gate.ts's resolveCrosscheck()
35
+ * returning null, and it is mandatory, not an optimisation.
36
+ * - exactly 1 active external: gating pool size 1 < 2 -> escalate
37
+ * insufficient-providers -> hold, every time. This is fail-closed and
38
+ * deliberate: the operator sees the reason and turns on a second
39
+ * provider or overrides by hand, rather than the epic silently going on
40
+ * the word of one external model.
41
+ * - 2+ active externals: a real quorum vote decides it.
42
+ * - shadow-only externals: verdicts are recorded (judge-verdict events)
43
+ * for calibration, but shadow mode has zero gating power — the outcome
44
+ * is whatever it would have been with no externals at all (`go`, since
45
+ * mechanical readiness already held).
46
+ */
47
+ const TERMINAL_OK_TASK_STATUSES = new Set(['completed', 'waived']);
48
+ /** The terminal-OK status that is a decision rather than a completion (D-120). */
49
+ const WAIVED_TASK_STATUS = 'waived';
50
+ /**
51
+ * Statuses that close a finding by DECIDING rather than by showing a fix
52
+ * (D-120). Disjoint from OPEN_FINDING_STATUSES by construction — a finding is
53
+ * open, earned-closed (`fix-verified`, `refuted`, `expired`), or one of these.
54
+ * The gate does not block on them, which is precisely why the judge is told:
55
+ * what nothing blocks on is what nobody re-reads.
56
+ */
57
+ const DISCRETIONARY_FINDING_STATUSES = new Set(['waived', AMENDED_STATUS]);
58
+ const GATE_OUTCOME_EVENT = 'gate-outcome';
59
+ const TASK_RESULT_EVENT = 'task-result-recorded';
60
+ /**
61
+ * Attach each row's gate evidence, folded from the same events the rows came
62
+ * from — no new I/O, the same way latestIntegrationCheck() and
63
+ * latestSpecReview() read the events runEpicVerdict already holds.
64
+ *
65
+ * Ids compare bare (D-46/P9-29): the fold row may carry `task-1` while the gate
66
+ * stamped `epic-1/task-1`, and comparing raw would read every such task as
67
+ * ungated — a false blocker is as bad here as the false pass this closes.
68
+ */
69
+ export function withGateEvidence(tasks, events, epicId) {
70
+ const gated = new Set();
71
+ const recorded = new Set();
72
+ for (const { record } of events) {
73
+ if (!record.task_id)
74
+ continue;
75
+ if (record.event_type === GATE_OUTCOME_EVENT)
76
+ gated.add(bareTaskId(epicId, record.task_id));
77
+ else if (record.event_type === TASK_RESULT_EVENT)
78
+ recorded.add(bareTaskId(epicId, record.task_id));
79
+ }
80
+ return tasks.map((t) => {
81
+ const bare = bareTaskId(epicId, t.taskId);
82
+ return { ...t, gate: { gateOutcome: gated.has(bare), resultRecorded: recorded.has(bare) } };
83
+ });
84
+ }
85
+ const WAVE_VERDICTS = [
86
+ 'parallel',
87
+ 'partial',
88
+ 'serialized',
89
+ 'single',
90
+ 'unobserved',
91
+ ];
92
+ /**
93
+ * Reduce one epic's waves to the fact a close records. Pure, and separate from
94
+ * `summariseWaveConcurrency` on purpose: that one scores an operator's audit
95
+ * across every epic in a log and returns an exit code, and this one answers a
96
+ * single epic with no verdict of its own to render.
97
+ */
98
+ export function summariseEpicConcurrency(waves) {
99
+ const verdicts = Object.fromEntries(WAVE_VERDICTS.map((v) => [v, 0]));
100
+ for (const wave of waves)
101
+ verdicts[wave.verdict] += 1;
102
+ return {
103
+ waves: waves.length,
104
+ verdicts,
105
+ widest: {
106
+ declared: waves.reduce((max, wave) => Math.max(max, wave.declared.length), 0),
107
+ observed: waves.reduce((max, wave) => Math.max(max, wave.peak), 0),
108
+ },
109
+ unobserved: waves.filter((wave) => wave.verdict === 'unobserved').map((wave) => wave.eventId),
110
+ problem: null,
111
+ };
112
+ }
113
+ /** Nothing the log could be folded into a width, and why. */
114
+ const UNREADABLE_CONCURRENCY = (problem) => ({
115
+ waves: 0,
116
+ verdicts: Object.fromEntries(WAVE_VERDICTS.map((v) => [v, 0])),
117
+ widest: { declared: 0, observed: 0 },
118
+ unobserved: [],
119
+ problem,
120
+ });
121
+ /**
122
+ * Fold the epic's waves off already-read events, reporting a refusal rather
123
+ * than propagating it. See {@link EpicConcurrency.problem}: width never blocks
124
+ * a close, so nothing about measuring it may be able to stop one.
125
+ */
126
+ export function readEpicConcurrency(events, epicId) {
127
+ try {
128
+ return summariseEpicConcurrency(auditWaveConcurrency(events, { epicId }));
129
+ }
130
+ catch (err) {
131
+ return UNREADABLE_CONCURRENCY(err instanceof Error ? err.message : String(err));
132
+ }
133
+ }
134
+ /**
135
+ * D-42/P9-26: every gate this factory runs, runs inside a task worktree, so
136
+ * every quality claim it makes is a claim about a worktree. The dogfood epic
137
+ * reached a `ship` verdict with six green per-task gates and not one command
138
+ * ever run against the assembled branch — which was broken, and stayed broken
139
+ * until a human typed `pnpm lint` at the root. These blockers are the epic
140
+ * gate refusing to certify what it has not seen: an assembled branch is
141
+ * shippable only when the suite ran at the integration root, passed, and ran
142
+ * against the commit the branch is on now.
143
+ */
144
+ function integrationBlockers(epicId, integration) {
145
+ const branch = `smith/${epicId}/${RESERVED_TASK_ID}`;
146
+ const { check, headSha } = integration;
147
+ if (check === null) {
148
+ return [
149
+ `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).`,
150
+ ];
151
+ }
152
+ // Unknown head: the record cannot be shown to cover anything. Fail closed —
153
+ // the alternative is trusting a sha nobody can compare against.
154
+ if (headSha === null) {
155
+ return [
156
+ `Could not read the head of ${branch}, so the integration-root check recorded at ${check.headSha.slice(0, 8)} cannot be shown to cover it.`,
157
+ ];
158
+ }
159
+ if (check.headSha !== headSha) {
160
+ return [
161
+ `The integration-root check for "${epicId}" is stale: it ran against ${check.headSha.slice(0, 8)}, and ${branch} is now at ${headSha.slice(0, 8)}. Anything merged since is uncertified.`,
162
+ ];
163
+ }
164
+ if (!check.pass) {
165
+ const failed = check.results.filter((r) => !r.pass).map((r) => r.name);
166
+ const named = failed.length > 0 ? failed.join(', ') : 'unnamed check(s)';
167
+ return [
168
+ `The integration-root check for "${epicId}" failed at ${headSha.slice(0, 8)}: ${named}.`,
169
+ ];
170
+ }
171
+ return [];
172
+ }
173
+ /**
174
+ * Pure readiness check, no I/O: given the epic's own task rows (foldTasks()
175
+ * output, already filtered to this epic) and its findings (listFindings()
176
+ * output), decide whether the epic is mechanically ready to open its
177
+ * integration PR. "Terminal-OK" mirrors db/queries.ts's
178
+ * MILESTONE_COMPLETE_TASK_STATUSES precedent (completed, waived) — a
179
+ * superseded/failed/escalated/blocked/in-progress/etc. task is not one of
180
+ * those, so it blocks. "Open" finding mirrors db/queries.ts's
181
+ * OPEN_FINDING_STATUSES precedent (raised, confirmed, fix-pending,
182
+ * fix-landed, amend-pending) — a finding only stops being open at
183
+ * fix-verified, waived, or amended. `amend-pending` is the D-127 case: the
184
+ * amendment is written but the tasks it names have not landed, so it is open
185
+ * here until they do, and only then does closeEpic take it to `amended`.
186
+ *
187
+ * `integration` is REQUIRED, deliberately. An optional parameter defaulting
188
+ * to "no check needed" would recreate the exact hole D-42/P9-26 closes: the
189
+ * one caller that forgot it would get a `ship` verdict for an epic nothing
190
+ * had ever been run against, and it would look like every other green run.
191
+ * `mcp` is required for the same reason — pass MCP_SURFACE_NOT_REQUIRED to
192
+ * say "this epic owes no surface" out loud, so a skip reads as a decision in
193
+ * the diff rather than as an omission. `specReview` is required with no escape
194
+ * value at all: an epic can legitimately owe no MCP surface, but every epic
195
+ * has a plan, and only a review run after the code exists can see the defects
196
+ * the code reveals (P9-9/D-33). `goalCheck` is required on the same terms and
197
+ * for the sharper reason: every gate above it reads text the planner wrote, so
198
+ * a plan that decomposes the wrong goal passes all of them, and the roadmap
199
+ * goal is the only reference in this pipeline the planner did not author.
200
+ *
201
+ * `plan` is the epic's live plan roster, or null when it has no plan file.
202
+ * D-126: without it the roster is the event-log fold alone, so a task the plan
203
+ * claims and the log has no events for is not "not done" — it is absent, and
204
+ * absence votes yes. `envkit-mcp-surface` closed `go` on a four-task roster
205
+ * while its live plan v5 held five. The fifth had in fact been built and
206
+ * merged, which is the point: the gate certified an epic it had no record of
207
+ * the work for, and the code being there anyway was luck rather than evidence.
208
+ * Null is a real answer, not a default: Phase 9's
209
+ * epics were driven as punch-list branches with no plan file to consult, and a
210
+ * plan that does not exist must cast no vote rather than an unclearable one.
211
+ * The plan's own `task_status` never satisfies the gate — the plan file is
212
+ * operator-writable and the log is hash-chained, so a plan row claiming
213
+ * `completed` with nothing behind it is a claim, not evidence.
214
+ *
215
+ * `tasks` carries each row's gate evidence (D-138) — a bare foldTasks() row no
216
+ * longer typechecks here, deliberately. `envkit-mcp-followup` closed `ship`
217
+ * with three of its four tasks holding a `gate-outcome` someone had typed:
218
+ * their session contains exactly one `task-result-recorded`, for the one task
219
+ * actually replayed through the gate. The gate had been reading the record as
220
+ * the thing it records. A task claimed done now has to show the run.
221
+ *
222
+ * `quarantined` is `foldFindingsDetailed`'s skipped list (D-135): records the
223
+ * fold could not turn into findings. It defaults to `[]` — unlike `integration`
224
+ * and `mcp`, which are required precisely so a forgotten argument cannot
225
+ * manufacture a green — because omitting it leaves the gate exactly as
226
+ * informed as it was before this parameter existed, and the one production
227
+ * caller threads it from the same fold that produced `findings`.
228
+ *
229
+ * `concurrency` follows the same trailing-optional shape and for the same
230
+ * reason, but its default carries a stronger claim: `null` means nobody
231
+ * measured how wide this epic ran, and it is projected as `null` rather than
232
+ * dropped so it can never be read as "it ran fine". It contributes nothing to
233
+ * `blockers` — see {@link EpicConcurrency}.
234
+ */
235
+ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specReview, goalCheck, plan = null, quarantined = [], concurrency = null) {
236
+ const taskSummaries = tasks.map((t) => ({
237
+ taskId: t.taskId,
238
+ taskStatus: t.taskStatus,
239
+ }));
240
+ const nonTerminal = taskSummaries.filter((t) => !TERMINAL_OK_TASK_STATUSES.has(t.taskStatus));
241
+ // D-138: only tasks claimed done are asked for evidence. One still in flight
242
+ // has not been gated yet and already blocks for not being terminal-OK —
243
+ // repeating it here would make an in-progress task read like a forged one.
244
+ const ungated = tasks.filter((t) => TERMINAL_OK_TASK_STATUSES.has(t.taskStatus) && !(t.gate.gateOutcome && t.gate.resultRecorded));
245
+ const ungatedTasks = ungated.map((t) => ({
246
+ taskId: t.taskId,
247
+ taskStatus: t.taskStatus,
248
+ }));
249
+ // Both registers spell ids either way (D-46/P9-29), so compare on the bare
250
+ // form or every planned task reads as undispatched.
251
+ const logged = new Set(taskSummaries.map((t) => bareTaskId(epicId, t.taskId)));
252
+ const undispatchedTasks = (plan?.tasks ?? []).filter((t) => !logged.has(bareTaskId(epicId, t.taskId)));
253
+ const toFindingSummary = (f) => ({
254
+ findingId: f.finding_id,
255
+ taskId: f.task_id,
256
+ severity: f.severity,
257
+ findingStatus: f.finding_status,
258
+ summary: f.summary,
259
+ });
260
+ const openFindings = [];
261
+ const discretionaryFindings = [];
262
+ const satisfiedAmendments = [];
263
+ const findingBlockers = [];
264
+ for (const f of findings) {
265
+ if (!OPEN_FINDING_STATUSES.has(f.finding_status)) {
266
+ // Closed either way, so the readiness answer is the same; D-120 keeps
267
+ // the two kinds of closed apart for the judge, which is the only reader
268
+ // that can ask whether a waiver should have been one.
269
+ if (DISCRETIONARY_FINDING_STATUSES.has(f.finding_status))
270
+ discretionaryFindings.push(toFindingSummary(f));
271
+ continue;
272
+ }
273
+ if (f.finding_status !== AMEND_PENDING_STATUS) {
274
+ openFindings.push(toFindingSummary(f));
275
+ findingBlockers.push(`Finding "${f.finding_id}" on "${f.task_id}" is still open (status: ${f.finding_status}, severity: ${f.severity}).`);
276
+ continue;
277
+ }
278
+ // D-127 Part B: an amend-pending finding is discharged by its own
279
+ // obligation — every id in amends_task_ids landed terminal-OK at or after
280
+ // amends_plan_version — not by the raw status alone. Ids compare bare
281
+ // (D-46/P9-29): amends_task_ids comes from the plan side, the fold rows
282
+ // from the event side, and the two registers spell ids either way.
283
+ const obligationIds = f.amends_task_ids ?? [];
284
+ const version = f.amends_plan_version;
285
+ // The rows that did discharge, kept as they are found: closeEpic has to
286
+ // hand them to transition() as evidence, and re-deriving them there would
287
+ // be the same computation answering the same question twice.
288
+ const satisfiedBy = [];
289
+ // D-21: `amends_task_ids` is typed `string[]`, but it is folded off an
290
+ // event payload nobody has validated — a malformed amendment (the same
291
+ // supersede-as-array mistake `draftNextVersion` now refuses) could still
292
+ // write a non-string entry, e.g. `[null, "epic-1/task-2"]`. `bareTaskId`
293
+ // calls `.startsWith` on its argument, so passing the malformed entry to
294
+ // it crashed `summarizeEpic` outright — and with it `smith epic verdict`
295
+ // and `smith epic close` — rendering nothing at all. The house rule for a
296
+ // check that cannot answer (dispatch check / escalation check's
297
+ // `unverifiable`) is that it must not read as a pass: the malformed entry
298
+ // is named as its own blocker below and never reaches `bareTaskId`, but it
299
+ // still counts against discharge, so a corrupt amendment cannot silently
300
+ // satisfy itself. The well-formed ids sharing its list are unaffected —
301
+ // they are genuine obligations and are still evaluated normally.
302
+ // D-21 Part 4 review finding (S3 behavioral-drift): this used to filter on
303
+ // `typeof id !== 'string'` alone, so `""` read as well-formed here while
304
+ // repairObligation's guard 1 (findings.ts) already calls it corrupt --
305
+ // `bareTaskId`/`taskIdsMatch` can never match `""` to a real task id, so
306
+ // an empty entry sat outstanding forever, never malformed and never
307
+ // landable. isNonEmptyString is the shared definition now: epic.ts is the
308
+ // side that had to move, not the guard.
309
+ const malformedObligations = obligationIds.filter((id) => !isNonEmptyString(id));
310
+ const wellFormedObligationIds = obligationIds.filter(isNonEmptyString);
311
+ const outstanding = obligationIds.length === 0
312
+ ? // An amendment naming no ids can never be satisfied. transition()'s
313
+ // own amendment-without-obligation guard should have refused this at
314
+ // the write (D-127), but a hand-edited or pre-guard record must not
315
+ // silently read as done — null here, not [], is what distinguishes
316
+ // "nothing to wait on" from "waiting on nothing outstanding".
317
+ null
318
+ : wellFormedObligationIds.filter((id) => {
319
+ const bare = bareTaskId(epicId, id);
320
+ const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === bare);
321
+ const landed = row !== undefined &&
322
+ TERMINAL_OK_TASK_STATUSES.has(row.taskStatus) &&
323
+ version !== undefined &&
324
+ row.planVersion !== null &&
325
+ row.planVersion >= version;
326
+ if (landed && row.planVersion !== null)
327
+ satisfiedBy.push({ taskId: row.taskId, planVersion: row.planVersion });
328
+ return !landed;
329
+ });
330
+ if (malformedObligations.length === 0 && outstanding !== null && outstanding.length === 0) {
331
+ satisfiedAmendments.push({
332
+ ...toFindingSummary(f),
333
+ satisfiedBy,
334
+ // D-21 Part 4: never let a clean discharge read as ordinary when the
335
+ // obligation it rested on was corrected -- carry the repair's own
336
+ // reason, omitted entirely (not merely undefined) when there was none.
337
+ ...(f.obligation_repair_reason !== undefined
338
+ ? { repairedObligationReason: f.obligation_repair_reason }
339
+ : {}),
340
+ });
341
+ continue;
342
+ }
343
+ openFindings.push(toFindingSummary(f));
344
+ if (malformedObligations.length > 0) {
345
+ findingBlockers.push(`Finding "${f.finding_id}" carries ${malformedObligations.length} malformed ` +
346
+ `amends_task_ids entr${malformedObligations.length === 1 ? 'y' : 'ies'} ` +
347
+ `(${malformedObligations.map(describeMalformedTaskId).join(', ')}), not a task id, so it cannot ` +
348
+ 'be checked as landed — this finding cannot be counted discharged. Repair or ' +
349
+ 'supersede the amendment record.');
350
+ }
351
+ if (outstanding === null) {
352
+ 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.`);
353
+ }
354
+ else if (outstanding.length > 0) {
355
+ 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.`);
356
+ }
357
+ }
358
+ const blockers = [
359
+ // An epic with no tasks is vacuously "all tasks done", which would let a
360
+ // typo'd --epic (or an epic whose plan was never dispatched) sail through
361
+ // as `go`. Nothing to integrate is a hold, not a pass.
362
+ ...(taskSummaries.length === 0
363
+ ? [`Epic "${epicId}" has no tasks in the event log — nothing to integrate.`]
364
+ : []),
365
+ ...nonTerminal.map((t) => `Task "${t.taskId}" is not terminal-OK (status: ${t.taskStatus}).`),
366
+ ...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}).`),
367
+ ...ungated.map((t) => t.gate.gateOutcome
368
+ ? `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.`
369
+ : `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.`),
370
+ ...findingBlockers,
371
+ // A record the fold could not read is a finding of UNKNOWN status, and
372
+ // the open/closed split above simply does not contain it. Treating it as
373
+ // closed is absence read as a yes — the same error as counting an
374
+ // undispatched task done (D-126).
375
+ ...quarantined.map((q) => `Finding record ${q.event_id}${q.finding_id ? ` ("${q.finding_id}")` : ''} could not be read: ${q.reason}. Its status is unknown, so it cannot be counted as closed — repair or supersede the record before closing "${epicId}".`),
376
+ ...integrationBlockers(epicId, integration),
377
+ // docs/standards/mcp.md step 4: the surface milestone's epics do not close
378
+ // while `smith mcp check` is red.
379
+ ...mcpBlockers(epicId, mcp),
380
+ // The roster is what knows the live plan version, so the review's own
381
+ // plan_version has something to be stale against (D-125).
382
+ ...specReviewBlockers(epicId, specReview, plan?.version ?? null),
383
+ ...goalCheckBlockers(epicId, goalCheck, plan?.version ?? null, plan?.tasks.map((t) => t.taskId) ?? []),
384
+ ];
385
+ return {
386
+ epicId,
387
+ tasks: taskSummaries,
388
+ // Undispatched tasks are unfinished work, so the one number the judge
389
+ // prompt and the closed record both read has to include them.
390
+ nonTerminalTaskCount: nonTerminal.length + undispatchedTasks.length,
391
+ planVersion: plan?.version ?? null,
392
+ undispatchedTasks,
393
+ ungatedTasks,
394
+ waivedTasks: taskSummaries.filter((t) => t.taskStatus === WAIVED_TASK_STATUS),
395
+ openFindings,
396
+ discretionaryFindings,
397
+ satisfiedAmendments,
398
+ integration,
399
+ mcp,
400
+ specReview,
401
+ goalCheck,
402
+ concurrency,
403
+ blockers,
404
+ mechanicallyReady: blockers.length === 0,
405
+ };
406
+ }
407
+ const findingLine = (f) => ` ${f.findingId} (${f.taskId}, ${f.severity}, ${f.findingStatus}): ${f.summary}`;
408
+ /** An empty list rendered as nothing reads as a section the prompt forgot. */
409
+ const listOr = (lines, empty = ' (none)') => lines.length > 0 ? lines : [empty];
410
+ /**
411
+ * Whether a recorded sha still covers the branch. Every caller here runs after
412
+ * the matching blocker has already passed, so `(the current head)` is the
413
+ * expected answer — the other two exist so that a record which somehow reached
414
+ * a judge without covering anything says so rather than showing a bare sha the
415
+ * judge has no way to place.
416
+ */
417
+ function coverage(recorded, current) {
418
+ if (current === null)
419
+ return ' (the current head could not be read)';
420
+ if (current === recorded)
421
+ return ' (the current head)';
422
+ return ` (stale — the branch is now at ${current.slice(0, 8)})`;
423
+ }
424
+ /**
425
+ * D-198. Both null branches used to say the same thing, and the violation
426
+ * branch said `[object Object]` — `violations` holds records, and `join` calls
427
+ * String() on each. The one sentence describing HOW a surface is red carried
428
+ * nothing a judge could be wrong about, which is the material D-120 exists to
429
+ * supply. mcpBlockers renders the same records correctly two functions over.
430
+ */
431
+ function mcpVerdict(mcp) {
432
+ if (mcp.check === null) {
433
+ return mcp.problem === 'unreadable'
434
+ ? 'no verdict could be rendered — the manifest exists and could not be read'
435
+ : 'no verdict could be rendered — there is no manifest';
436
+ }
437
+ if (mcp.check.ok)
438
+ return 'clean';
439
+ const rules = mcp.check.violations.map((v) => `${v.rule} at ${v.path} — ${v.message}`);
440
+ return `${mcp.check.violations.length} violation(s): ${rules.join('; ')}`;
441
+ }
442
+ /**
443
+ * Pure prompt builder, mirrors quorum.ts's findingJudgeRequest() style and
444
+ * trust boundary: never file contents or a diff — this module is a fold over
445
+ * events and has no source to hand over.
446
+ *
447
+ * D-120 is why it carries more than the claim. The trigger fired twice on
448
+ * byte-identical input (4 tasks completed, 0 non-terminal, 0 open findings) and
449
+ * answered `refute` then `confirm`, same model, minutes apart. Neither answer
450
+ * was derivable: the old prompt was a refute mandate, a status table, and the
451
+ * sentence "you do not have file contents or the diff". A judge that takes the
452
+ * mandate seriously must refute every epic forever; one that does not must
453
+ * confirm every epic forever. Which you get is sampling noise, and the day a
454
+ * provider is promoted to `mode: active` that coin acquires gating power.
455
+ *
456
+ * The fix is not a sharper mandate. It is material the judge can be WRONG
457
+ * about, and the summary already holds it: which commands actually ran against
458
+ * the assembled branch and how each exited, who closed the spec review and
459
+ * against what, the MCP surface verdict, and every closure a person decided
460
+ * rather than earned. summarizeEpic drops all of it from `blockers` because
461
+ * none of it blocks — which is exactly the argument for showing it here. A
462
+ * mechanically perfect epic can be a single `lint` command and four waivers,
463
+ * and that is a judgement about a list, which is the one thing a judge can do
464
+ * that a blocker cannot.
465
+ *
466
+ * NEVER include CheckResult.tail: it is the last 50 lines of combined
467
+ * stdout+stderr from commands run at the integration root, and this prompt goes
468
+ * to an external provider (guardrails.md, "No secrets in outputs"). The name
469
+ * and the exit code are the refutable part; the output is not.
470
+ */
471
+ export function epicVerdictJudgeRequest(summary, budget) {
472
+ const taskLines = summary.tasks.length > 0
473
+ ? summary.tasks.map((t) => ` ${t.taskId}: ${t.taskStatus}`).join('\n')
474
+ : ' (no tasks)';
475
+ // The plan's side of the roster, stated separately: these ids have no events
476
+ // at all, so their status is what the plan claims, not what happened (D-126).
477
+ const undispatchedLines = summary.undispatchedTasks.length > 0
478
+ ? summary.undispatchedTasks
479
+ .map((t) => ` ${t.taskId}: plan v${summary.planVersion} says ${t.taskStatus}`)
480
+ .join('\n')
481
+ : summary.planVersion === null
482
+ ? ' (no plan file for this epic)'
483
+ : ' (none)';
484
+ const findingLines = summary.openFindings.length > 0 ? summary.openFindings.map(findingLine).join('\n') : ' (none)';
485
+ // The assembled branch. `integrationBlockers` above holds the epic on every
486
+ // shape but this one, so by the time a judge runs the check is present,
487
+ // passing, and current — the refutable question left is what it RAN.
488
+ const branch = `smith/${summary.epicId}/${RESERVED_TASK_ID}`;
489
+ const { check, headSha } = summary.integration;
490
+ const integrationLines = check === null
491
+ ? [` There is no integration-root check on record for ${branch}.`]
492
+ : [
493
+ ` Branch: ${branch}`,
494
+ ` Checked at commit ${check.headSha.slice(0, 8)}${coverage(check.headSha, headSha)}, recorded ${check.ts}`,
495
+ ` Result: ${check.pass ? 'passed' : 'FAILED'} — ${check.results.length} command${check.results.length === 1 ? '' : 's'} ran:`,
496
+ // Name and exit code only — see this function's header on tail.
497
+ ...listOr(check.results.map((r) => ` ${r.name}: ${r.pass ? 'passed' : 'FAILED'} (exit ${r.exitCode})`), ' (none — the check recorded no commands at all)'),
498
+ ];
499
+ const review = summary.specReview.review;
500
+ const specReviewLines = review === null
501
+ ? [' There is no closing spec review on record for this epic.']
502
+ : [
503
+ ` Reviewed by: ${review.reviewedBy}`,
504
+ ` Against commit ${review.headSha.slice(0, 8)}${coverage(review.headSha, summary.specReview.headSha)}, plan v${review.planVersion}`,
505
+ ` Findings that review raised: ${review.findingIds.length}${review.findingIds.length > 0 ? ` (${review.findingIds.join(', ')})` : ''}`,
506
+ ];
507
+ const goalText = summary.goalCheck.goal;
508
+ const goalRecord = summary.goalCheck.check;
509
+ const goalCheckLines = goalText.goal === null
510
+ ? [
511
+ ` No roadmap milestone states a goal for this epic${goalText.milestoneId === null ? '' : ` (milestone ${goalText.milestoneId})`}.`,
512
+ ]
513
+ : [
514
+ ` Goal (milestone ${goalText.milestoneId}): ${goalText.goal}`,
515
+ ...(goalRecord === null
516
+ ? [' There is no spec-vs-goal check on record for this epic.']
517
+ : [
518
+ ` Checked by: ${goalRecord.checkedBy}, against plan v${goalRecord.planVersion}`,
519
+ ` Goal digest read: ${goalRecord.goalDigest}${goalRecord.goalDigest === goalText.digest ? ' (current)' : ` — the roadmap now digests to ${goalText.digest}`}`,
520
+ // Verdict per clause, in the goal's own order. An out-of-scope
521
+ // dismissal prints its reason: it is the one verdict a judge
522
+ // can use to make a clause disappear, so it is the one an
523
+ // operator most needs to read back.
524
+ ...goalRecord.coverage.map((entry, i) => ` Clause ${i + 1} [${entry.verdict}]: ${entry.clause}${entry.verdict === 'covered'
525
+ ? ` — ${(entry.taskIds ?? []).join(', ')}`
526
+ : entry.verdict === 'out-of-scope'
527
+ ? ` — dismissed: ${entry.reason ?? ''}`
528
+ : ''}`),
529
+ ]),
530
+ ];
531
+ const mcpLine = !summary.mcp.required
532
+ ? ' This epic owes no MCP surface.'
533
+ : ` Milestone ${summary.mcp.milestoneId ?? '(unnamed)'}, manifest ${summary.mcp.manifestPath ?? '(none)'}: ${mcpVerdict(summary.mcp)}`;
534
+ // The parallelism fact, stated whether or not it flatters the epic. Null is
535
+ // rendered rather than dropped: a missing section reads as an older prompt,
536
+ // and "nobody measured" is a different answer from "it ran one at a time".
537
+ const concurrency = summary.concurrency;
538
+ const concurrencyLines = concurrency === null
539
+ ? [' Nothing measured this. No wave record was read for this epic.']
540
+ : concurrency.problem !== null
541
+ ? [
542
+ ` The wave record could not be read, so nothing here is a count: ${concurrency.problem}`,
543
+ ]
544
+ : concurrency.waves === 0
545
+ ? [' Waves admitted: 0 — no wave was ever cut under this epic id.']
546
+ : [
547
+ ` Waves admitted: ${concurrency.waves}`,
548
+ ` Width: ${concurrency.widest.declared} admitted at the widest, ${concurrency.widest.observed} ever in flight at once`,
549
+ ` Verdict per wave: ${WAVE_VERDICTS.map((v) => `${v} ${concurrency.verdicts[v]}`).join(', ')}`,
550
+ ` Waves with no dispatch on record: ${concurrency.unobserved.length}`,
551
+ ...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)'),
552
+ ];
553
+ const prompt = [
554
+ 'You are an adversarial critic in an automated epic-close gate.',
555
+ `Epic "${summary.epicId}" is claimed ready to open its integration PR.`,
556
+ 'Your mandate is to REFUTE that claim: assume it is NOT ready until the',
557
+ 'evidence below forces you to agree. Confirm only if every task is',
558
+ 'genuinely done and no finding is genuinely still open.',
559
+ '',
560
+ `Epic: ${summary.epicId}`,
561
+ `Tasks not yet terminal-OK: ${summary.nonTerminalTaskCount}`,
562
+ 'Tasks (from the event log):',
563
+ taskLines,
564
+ '',
565
+ `Planned but never dispatched: ${summary.undispatchedTasks.length}`,
566
+ undispatchedLines,
567
+ '',
568
+ `Open findings: ${summary.openFindings.length}`,
569
+ findingLines,
570
+ '',
571
+ 'The assembled branch — what actually ran against the merged code:',
572
+ ...integrationLines,
573
+ '',
574
+ 'Closing spec review (run after the code existed, P9-9/D-33):',
575
+ ...specReviewLines,
576
+ '',
577
+ 'Spec vs goal — the plan against the one reference the planner did not write:',
578
+ ...goalCheckLines,
579
+ '',
580
+ 'MCP surface:',
581
+ mcpLine,
582
+ '',
583
+ 'How wide this epic ran — this factory claims a plan is built by many',
584
+ 'agents working its tasks at the same time, and this is the log read back:',
585
+ ...concurrencyLines,
586
+ ' A narrow epic is not by itself a refutation: a plan whose tasks genuinely',
587
+ ' depend on one another has nothing to run side by side, and refuting on',
588
+ ' width alone would make your verdict a constant rather than a measurement.',
589
+ ' What is refutable here is a wave whose tasks were admitted and nothing',
590
+ ' shows them running: that is a declaration with no work behind it.',
591
+ '',
592
+ 'Discretionary closures — decided by a person, not shown by the machine:',
593
+ `Tasks waived rather than completed: ${summary.waivedTasks.length}`,
594
+ ...listOr(summary.waivedTasks.map((t) => ` ${t.taskId}`)),
595
+ `Findings closed by waiver or amendment: ${summary.discretionaryFindings.length}`,
596
+ ...listOr(summary.discretionaryFindings.map(findingLine)),
597
+ `Amendments this close will discharge: ${summary.satisfiedAmendments.length}`,
598
+ ...listOr(summary.satisfiedAmendments.map((f) => `${findingLine(f)} — discharged by ${f.satisfiedBy
599
+ .map((d) => `${d.taskId} at plan v${d.planVersion}`)
600
+ .join(', ')}` +
601
+ // D-21 Part 4: a clean discharge must never read as ordinary when
602
+ // the obligation it rested on was corrected -- the judge is the
603
+ // reader this honesty requirement exists for.
604
+ (f.repairedObligationReason
605
+ ? ` (obligation repaired: ${f.repairedObligationReason})`
606
+ : ''))),
607
+ '',
608
+ "What you have: the roster above and each task's status, the commands that",
609
+ 'ran against the assembled branch and whether each passed, the closing spec',
610
+ 'review, the MCP surface verdict, how wide the waves ran, and every closure',
611
+ 'a person decided.',
612
+ 'What you do NOT have: file contents, the diff for any task, or the output',
613
+ 'of any command — only its name and how it exited.',
614
+ '',
615
+ 'Refute if a line above is inconsistent with the readiness claim, and name',
616
+ 'the line you are refuting. Do not refute merely because the diff is absent:',
617
+ 'that is true of every epic this gate will ever see, so it distinguishes',
618
+ 'nothing and makes your verdict a constant rather than a measurement.',
619
+ '',
620
+ 'Return only JSON matching judge-verdict.schema.json:',
621
+ '{"verdict": "confirm" | "refute", "rationale": "<one or two sentences>"}',
622
+ '"confirm" = the epic is genuinely ready to open its integration PR.',
623
+ '"refute" = it is not ready.',
624
+ ].join('\n');
625
+ return {
626
+ kind: 'verify',
627
+ taskId: `${summary.epicId}/${RESERVED_TASK_ID}`,
628
+ inputRefs: { epic_id: summary.epicId },
629
+ prompt,
630
+ schemaName: 'judge-verdict',
631
+ budget,
632
+ };
633
+ }
634
+ /** A judge call is a network round-trip, not a test — same rationale and same numbers as
635
+ * gate.ts's DEFAULT_JUDGE_BUDGET; not imported from there since gate.ts doesn't export it and
636
+ * epic.ts must not depend on gate.ts (parallel quorum_triggers hosts, not a hierarchy). */
637
+ const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
638
+ function epicQuorumDecisionPayload(quorum, epicId, finderProvider, ready) {
639
+ const gating = quorum.gating;
640
+ return {
641
+ task_id: `${epicId}/${RESERVED_TASK_ID}`,
642
+ epic_id: epicId,
643
+ finding_id: null,
644
+ trigger_reason: 'epic-final-verdict',
645
+ finder_provider: finderProvider,
646
+ outcome: gating.outcome,
647
+ decision: gating.outcome === 'decided' ? gating.decision : null,
648
+ agreement: gating.outcome === 'decided' ? gating.agreement : null,
649
+ gating_participants: gating.outcome === 'decided' ? gating.participants : [],
650
+ escalation_reason: gating.outcome === 'escalate' ? gating.reason : null,
651
+ rationales: gating.outcome === 'escalate' ? gating.rationales : [],
652
+ participants: quorum.participants.map((p) => ({
653
+ provider: p.provider,
654
+ mode: p.mode,
655
+ ok: p.ok,
656
+ verdict: p.verdict,
657
+ excluded_as_finder: p.excludedAsFinder,
658
+ })),
659
+ native_verdict: 'confirm',
660
+ /** Whether the epic actually goes to integration after the quorum spoke. */
661
+ ready,
662
+ };
663
+ }
664
+ /**
665
+ * Read the epic's latest plan version and reduce it to the roster the gate
666
+ * reads. The only I/O between the plan file and summarizeEpic (D-126).
667
+ *
668
+ * A plan that cannot be read at all is treated as no plan rather than as a
669
+ * thrown error: `epic verdict` is an operator's read-only question, and
670
+ * failing it closed on a malformed file would make the answer unreachable
671
+ * exactly when the operator most needs it. The blocker set is unchanged in
672
+ * that case, and `planVersion: null` records that nothing was consulted.
673
+ */
674
+ function resolvePlanRoster(epicId, planOpts) {
675
+ const version = latestPlanVersion(epicId, planOpts);
676
+ if (version === null)
677
+ return null;
678
+ try {
679
+ const plan = loadPlan(epicId, version, planOpts);
680
+ return {
681
+ version,
682
+ tasks: livePlanTasks(plan).map((t) => ({ taskId: t.task_id, taskStatus: t.task_status })),
683
+ };
684
+ }
685
+ catch {
686
+ return null;
687
+ }
688
+ }
689
+ /**
690
+ * Operator-invoked verdict for one epic: mechanical readiness first, then
691
+ * (only when mechanically ready and it isn't free to skip) a cross-provider
692
+ * quorum on the claim "this epic is ready to open its integration PR". Never
693
+ * dispatches anything itself — the caller decides what to do with `go`/`hold`.
694
+ */
695
+ export async function runEpicVerdict(input, ctx, opts = {}) {
696
+ // Lineage (D-119). An epic is not a session: `validateCausalParent`'s own
697
+ // header calls chaining a fresh session onto a full one "the documented way
698
+ // to run a large epic", and this verdict read one session. Split the real
699
+ // dogfood-mcp-1 log in two and `hold` on eleven open findings became `go` on
700
+ // none — not a wrong number in a report, a close that the gate was built to
701
+ // refuse.
702
+ const events = await readLineageEvents(ctx.sessionId, opts);
703
+ // Membership is the row's field, not a prefix test on its id (D-49/P9-10):
704
+ // a task admitted under a bare id with the epic in its payload used to be
705
+ // invisible here, and an epic with real work in it read "no tasks".
706
+ // Gate evidence rides along from the same events (D-138) — no second read.
707
+ // The fold reports the status a task's record CLAIMS; withGateEvidence says
708
+ // whether the log holds the gate run behind the claim.
709
+ const tasks = withGateEvidence(foldTasks(events).filter((t) => t.epicId === input.epicId), events, input.epicId);
710
+ // Folded from `events` rather than re-read via listFindings: the detailed
711
+ // form is the only one that reports what it had to quarantine (D-135), and
712
+ // the gate is precisely the caller that must not treat an unreadable
713
+ // finding record as one fewer open finding.
714
+ const { findings, skipped } = foldFindingsDetailed(events, { epic: input.epicId });
715
+ const summary = summarizeEpic(input.epicId, tasks, findings, {
716
+ check: latestIntegrationCheck(events, input.epicId),
717
+ headSha: input.integrationHeadSha,
718
+ }, input.mcp,
719
+ // Folded from the log this call already read, and pinned to the same head
720
+ // as the integration check: the closing spec review reads the assembled
721
+ // branch, so "current" means the same commit for both.
722
+ {
723
+ review: latestSpecReview(events, input.epicId),
724
+ headSha: input.integrationHeadSha,
725
+ },
726
+ // Same shape, other axis: the recorded check comes from the log this call
727
+ // already read, and the goal it is measured against comes from the
728
+ // roadmap the caller resolved — a check is only evidence about the text it
729
+ // read, so the pair is what makes "current" decidable.
730
+ { check: latestGoalCheck(events, input.epicId), goal: input.goal }, resolvePlanRoster(input.epicId, input.planOpts ?? {}), skipped,
731
+ // Off the same lineage `events` every other fact here came from — the
732
+ // wave record is in that log already, so measuring width costs this call
733
+ // no second read and no second command anyone has to remember to type.
734
+ readEpicConcurrency(events, input.epicId));
735
+ // Step 1 — mechanical_oracles_first, literally: a deterministic blocker is
736
+ // final. Zero judge calls, zero events (read-only projection).
737
+ if (!summary.mechanicallyReady) {
738
+ return { outcome: 'hold', epicId: input.epicId, summary, reason: 'mechanical-blockers' };
739
+ }
740
+ const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
741
+ const providers = enabledExternalProviders(policy);
742
+ // Step 2 — zero-cost-by-default short-circuit, see module header §4.
743
+ if (providers.length === 0) {
744
+ return { outcome: 'go', epicId: input.epicId, summary };
745
+ }
746
+ const nativeProvider = nativeProviderName(policy);
747
+ const budget = input.crosscheck?.budget ?? DEFAULT_JUDGE_BUDGET;
748
+ const rationale = `${input.epicId}: ${summary.tasks.length} task(s), all terminal-OK; 0 open findings.`;
749
+ // Step 3 — the claim is native's own ("ready"); finder_ne_critic excludes
750
+ // it from gating (§4).
751
+ const quorum = await runQuorumCase({
752
+ taskId: `${input.epicId}/${RESERVED_TASK_ID}`,
753
+ triggerReason: 'epic-final-verdict',
754
+ finderProvider: nativeProvider,
755
+ kind: 'verify',
756
+ native: { provider: nativeProvider, verdict: 'confirm', rationale },
757
+ providers,
758
+ request: epicVerdictJudgeRequest(summary, budget),
759
+ policy,
760
+ quorumPolicy: { minProviders: policy.quorumRule.minProviders },
761
+ fetchImpl: input.crosscheck?.fetchImpl,
762
+ }, ctx, opts);
763
+ // Step 4 — map the quorum's gating decision to go/hold. escalate only
764
+ // holds when an ACTIVE judge actually ran (gate.ts's crossCheckFinding
765
+ // rule, reused verbatim): shadow-only/all-failed participants have zero
766
+ // gating power by construction, so there's nothing for an operator to
767
+ // arbitrate and the epic goes exactly as it would have with 0 externals.
768
+ const gating = quorum.gating;
769
+ let outcome;
770
+ if (gating.outcome === 'decided') {
771
+ outcome =
772
+ gating.decision === 'confirm'
773
+ ? { outcome: 'go', epicId: input.epicId, summary, quorum }
774
+ : { outcome: 'hold', epicId: input.epicId, summary, reason: 'quorum-refuted', quorum };
775
+ }
776
+ else {
777
+ const hadActiveJudge = quorum.participants.some((p) => p.mode === 'active' && p.ok && !p.excludedAsFinder);
778
+ outcome = hadActiveJudge
779
+ ? { outcome: 'hold', epicId: input.epicId, summary, reason: gating.reason, quorum }
780
+ : { outcome: 'go', epicId: input.epicId, summary, quorum };
781
+ }
782
+ // Step 5 — emit exactly once, for any case that actually ran a quorum.
783
+ await appendEvent({
784
+ session_id: ctx.sessionId,
785
+ actor: ctx.actor ?? 'system',
786
+ event_type: 'quorum-decision',
787
+ task_id: `${input.epicId}/${RESERVED_TASK_ID}`,
788
+ plan_version: ctx.planVersion,
789
+ causal_parent: ctx.causalParent,
790
+ payload: epicQuorumDecisionPayload(quorum, input.epicId, nativeProvider, outcome.outcome === 'go'),
791
+ }, opts);
792
+ return outcome;
793
+ }
794
+ /**
795
+ * D-43/P9-27. Everything above is a read-only probe, and that is the point:
796
+ * in the zero-cost default config BOTH of runEpicVerdict's terminal outcomes
797
+ * return without appending anything, so asking "is this epic ready?" stays
798
+ * free and repeatable. The cost is that the dogfood epic was declared
799
+ * shippable, held on mechanical blockers, overridden by a human, merged and
800
+ * tagged — and the log recorded none of it. The verdict was a conversation,
801
+ * not a fact.
802
+ *
803
+ * `closeEpic` is the verb that makes it a fact. It runs the same probe and
804
+ * then writes down what the probe said, who closed on it, and — when a human
805
+ * closed over a hold — the blockers they overrode and why. An override stays
806
+ * possible, because a human shipping over a known carry-forward defect is
807
+ * legitimate; doing it silently is not.
808
+ */
809
+ export class EpicCloseError extends SmithError {
810
+ }
811
+ /**
812
+ * Re-exported, not re-declared — the same reason judges.ts re-exports
813
+ * `JUDGE_REPORT_EVENT_TYPE`. The registry folds this event now (D-187): it is
814
+ * what closes the agents still open when the verdict lands, so two copies of
815
+ * the literal would be two places to keep in sync, and the day they disagreed
816
+ * the registry would stop recognising the close this file writes.
817
+ */
818
+ export { EPIC_CLOSED_EVENT_TYPE };
819
+ /**
820
+ * snake_case projection of the summary, so the payload reads like every other
821
+ * event's.
822
+ *
823
+ * Every field is projected even when null or empty, for the reason spelled out
824
+ * beside `plan_version` below: an absent key reads as an older event rather
825
+ * than as nothing to report, and "nothing to report" is itself the answer a
826
+ * reader of a close most often needs.
827
+ */
828
+ function epicSummaryPayload(summary) {
829
+ const { check, headSha } = summary.integration;
830
+ const { review } = summary.specReview;
831
+ const taskRef = (t) => ({ task_id: t.taskId, task_status: t.taskStatus });
832
+ const findingRef = (f) => ({
833
+ finding_id: f.findingId,
834
+ task_id: f.taskId,
835
+ severity: f.severity,
836
+ finding_status: f.findingStatus,
837
+ summary: f.summary,
838
+ });
839
+ return {
840
+ tasks: summary.tasks.map((t) => ({ task_id: t.taskId, task_status: t.taskStatus })),
841
+ non_terminal_task_count: summary.nonTerminalTaskCount,
842
+ // Projected even when null/empty, for the same reason as spec_review below:
843
+ // "which plan version was consulted, and did it claim anything the log
844
+ // never saw" is exactly the question the envkit-mcp-surface close could not
845
+ // be asked afterwards (D-126).
846
+ plan_version: summary.planVersion,
847
+ undispatched_tasks: summary.undispatchedTasks.map((t) => ({
848
+ task_id: t.taskId,
849
+ plan_task_status: t.taskStatus,
850
+ })),
851
+ // D-120/D-138: the closures nobody re-derives afterwards. The judge is
852
+ // handed all of this (epicVerdictJudgeRequest), renders one verdict and is
853
+ // gone; this event is the only reader left. Without these three lists,
854
+ // "was this epic closed on waivers?" and "did any task close ungated?"
855
+ // are answerable for the length of one prompt and unanswerable after —
856
+ // and a `go` verdict is exactly the case where nothing else in the log
857
+ // says so, because none of it blocked.
858
+ ungated_tasks: summary.ungatedTasks.map(taskRef),
859
+ waived_tasks: summary.waivedTasks.map(taskRef),
860
+ open_findings: summary.openFindings.map(findingRef),
861
+ discretionary_findings: summary.discretionaryFindings.map(findingRef),
862
+ // D-127 Part B: satisfied amend-pending findings are not blockers, but
863
+ // closeEpic() has not discharged them yet at the point this payload is
864
+ // built for the verdict — projecting them here is what lets a reader see
865
+ // "this close is about to turn these amend-pending findings into amended"
866
+ // rather than inferring it from the finding-transitioned events after.
867
+ satisfied_amendments: summary.satisfiedAmendments.map((f) => ({
868
+ finding_id: f.findingId,
869
+ task_id: f.taskId,
870
+ severity: f.severity,
871
+ finding_status: f.findingStatus,
872
+ summary: f.summary,
873
+ // The evidence, not just the verdict: transition() will refuse to close
874
+ // these on anything less, so the record of the close carries the same
875
+ // proof the gate demanded rather than making a reader re-fold the tasks.
876
+ satisfied_by: f.satisfiedBy.map((row) => ({
877
+ task_id: row.taskId,
878
+ plan_version: row.planVersion,
879
+ })),
880
+ // D-21 Part 4: this record outlives the session -- it is what an
881
+ // operator auditing the close months later actually reads -- so the
882
+ // honesty requirement has to reach it, not only the live verdict
883
+ // epicVerdictJudgeRequest hands the judge. Omitted entirely (not
884
+ // merely null) when the finding's obligation was never repaired.
885
+ ...(f.repairedObligationReason !== undefined
886
+ ? { repaired_obligation_reason: f.repairedObligationReason }
887
+ : {}),
888
+ })),
889
+ integration: {
890
+ head_sha: headSha,
891
+ check: check === null ? null : { branch: check.branch, head_sha: check.headSha, pass: check.pass },
892
+ },
893
+ // The blockers list already names a broken rule, but it does not say the
894
+ // surface was checked, which manifest was read, or whether the epic was in
895
+ // the surface milestone at all — and on a green surface it says nothing.
896
+ //
897
+ // Each violation is projected as its rule and path only. A violation's
898
+ // `message` is manifest-derived text and this payload is persisted, which
899
+ // is the same reason D-198 keeps the JSON parser's message out of an
900
+ // mcpBlockers string.
901
+ mcp: {
902
+ required: summary.mcp.required,
903
+ milestone_id: summary.mcp.milestoneId,
904
+ manifest_path: summary.mcp.manifestPath,
905
+ problem: summary.mcp.problem,
906
+ check: summary.mcp.check === null
907
+ ? null
908
+ : {
909
+ ok: summary.mcp.check.ok,
910
+ violations: summary.mcp.check.violations.map((v) => ({ rule: v.rule, path: v.path })),
911
+ },
912
+ },
913
+ // Projected even when null: "no closing spec review" is the fact the
914
+ // epic-closed record most needs to carry, and an absent key would read
915
+ // as an older event rather than as a missing review.
916
+ spec_review: review === null
917
+ ? null
918
+ : {
919
+ plan_version: review.planVersion,
920
+ head_sha: review.headSha,
921
+ reviewed_by: review.reviewedBy,
922
+ finding_count: review.findingIds.length,
923
+ },
924
+ // Projected even at zero, and even as null, for the reason plan_version
925
+ // gives above. This is the only record that outlives the close, and the
926
+ // question it answers — did this epic actually run its plan in parallel,
927
+ // or did it run the plan one task at a time and call it a factory — has no
928
+ // other reader once the wave state dir is gone. Every key is spelled the
929
+ // same either side: the field names are single words, so there is no
930
+ // snake_case form of them to drift from.
931
+ concurrency: summary.concurrency === null
932
+ ? null
933
+ : {
934
+ waves: summary.concurrency.waves,
935
+ verdicts: { ...summary.concurrency.verdicts },
936
+ widest: { ...summary.concurrency.widest },
937
+ unobserved: [...summary.concurrency.unobserved],
938
+ problem: summary.concurrency.problem,
939
+ },
940
+ mechanically_ready: summary.mechanicallyReady,
941
+ };
942
+ }
943
+ export async function closeEpic(input, ctx, opts = {}) {
944
+ // D-45: `event tail` answers a typo'd session id with [] and exit 0. Close
945
+ // must not inherit that. Appending to an unknown session would create a
946
+ // brand-new log whose first line is "this epic is closed" — a record that
947
+ // is both true and unfindable, which is worse than the refusal.
948
+ //
949
+ // Deliberately `readEvents` and not the lineage read D-119 put everywhere
950
+ // else. The question here is "does THIS session have a log", and only this
951
+ // session's log answers it; a lineage read would be asking whether some
952
+ // ancestor exists, which is not what the refusal below says. Nothing is lost
953
+ // — every DECISION this function makes comes from runEpicVerdict, and that
954
+ // reads the lineage.
955
+ const events = await readEvents(ctx.sessionId, opts);
956
+ if (events.length === 0) {
957
+ 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 });
958
+ }
959
+ const rationale = input.overrideRationale?.trim() ?? '';
960
+ if (input.overrideRationale !== undefined && rationale === '') {
961
+ 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 });
962
+ }
963
+ const verdict = await runEpicVerdict(input, ctx, opts);
964
+ const blockers = verdict.outcome === 'hold' ? verdict.summary.blockers : [];
965
+ if (verdict.outcome === 'hold' && rationale === '') {
966
+ 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 });
967
+ }
968
+ // D-127 Part B: a satisfied amendment is not a blocker (summarizeEpic
969
+ // already left it out of `blockers`/`openFindings`), but it is not a fact
970
+ // yet either — nothing has told the finding it may leave amend-pending.
971
+ // Discharge every satisfied amendment here, before the epic-closed event,
972
+ // so a reader scanning the log in order sees the amendments land and THEN
973
+ // the close that depended on them. This runs unconditionally, whether the
974
+ // close is a clean verdict or an operator override over an unrelated
975
+ // blocker: satisfaction was computed as a pure fact about the amendment
976
+ // itself, independent of what else is or isn't wrong with the epic, so the
977
+ // override question (should THIS close proceed) has no bearing on whether
978
+ // THIS amendment already discharged.
979
+ for (const amendment of verdict.summary.satisfiedAmendments) {
980
+ await transition(amendment.findingId, AMENDED_STATUS, ctx, opts, {
981
+ amendsSatisfiedBy: amendment.satisfiedBy,
982
+ });
983
+ }
984
+ const closedBy = verdict.outcome === 'go' ? 'verdict' : 'operator-override';
985
+ const machineReason = verdict.outcome === 'hold' ? verdict.reason : null;
986
+ const stored = await appendEvent({
987
+ session_id: ctx.sessionId,
988
+ actor: ctx.actor ?? (closedBy === 'operator-override' ? 'operator' : 'system'),
989
+ event_type: EPIC_CLOSED_EVENT_TYPE,
990
+ // <epic>/integration, the established epic-level id. An unreserved
991
+ // suffix like <epic>/epic escapes foldTasks()'s isReservedRef guard and
992
+ // mints a phantom task row — which is exactly what the hand-written
993
+ // dogfood close did (D-44).
994
+ task_id: `${input.epicId}/${RESERVED_TASK_ID}`,
995
+ plan_version: ctx.planVersion,
996
+ causal_parent: ctx.causalParent,
997
+ payload: {
998
+ epic_id: input.epicId,
999
+ closed_by: closedBy,
1000
+ machine_verdict: verdict.outcome,
1001
+ machine_reason: machineReason,
1002
+ override_rationale: closedBy === 'operator-override' ? rationale : null,
1003
+ blockers,
1004
+ summary: epicSummaryPayload(verdict.summary),
1005
+ },
1006
+ }, opts);
1007
+ return {
1008
+ epicId: input.epicId,
1009
+ closedBy,
1010
+ machineVerdict: verdict.outcome,
1011
+ machineReason,
1012
+ overrideRationale: closedBy === 'operator-override' ? rationale : null,
1013
+ blockers,
1014
+ summary: verdict.summary,
1015
+ eventId: stored.event_id,
1016
+ ts: stored.record.ts,
1017
+ };
1018
+ }