@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,911 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { TASK_RESULT_EVENT_TYPE } from './agents-registry.js';
5
+ import { checkArtifacts } from './artifacts.js';
6
+ import { recordReattribution, routeFindings } from './attribution.js';
7
+ import { checkTaskBudget } from './budgets.js';
8
+ import { certifyCommit } from './commit.js';
9
+ import { collectCoverageEvidence } from './coverage.js';
10
+ import { loadCrosscheckPolicy } from './crosscheck.js';
11
+ import { DiffstatError, measureDiff } from './diffstat.js';
12
+ import { appendEvent, readLineageEvents } from './events.js';
13
+ import { findingScope, raiseFinding, SPEC_FINDING_SCOPE, transition, } from './findings.js';
14
+ import { outstandingJudges, readJudgeTurns } from './judges.js';
15
+ import { enabledExternalProviders, findingJudgeRequest, nativeProviderName, runQuorumCase, } from './quorum.js';
16
+ import { compileSchemas, validateRecord, } from './schemas.js';
17
+ import { canEscalate, decide } from './severity.js';
18
+ import { readAddedTasks } from './taskEvents.js';
19
+ import { taskIdsMatch } from './taskId.js';
20
+ import { loadTaxonomy } from './taxonomy.js';
21
+ import { run as runTestgate } from './testgate.js';
22
+ let cachedTaxonomy;
23
+ let cachedSchemas;
24
+ function resolveTaxonomyAndSchemas(opts) {
25
+ if (cachedTaxonomy === undefined)
26
+ cachedTaxonomy = loadTaxonomy();
27
+ if (cachedSchemas === undefined)
28
+ cachedSchemas = compileSchemas(cachedTaxonomy);
29
+ const taxonomy = opts.taxonomy ?? cachedTaxonomy;
30
+ const schemas = opts.schemas ?? cachedSchemas;
31
+ return { taxonomy, schemas };
32
+ }
33
+ async function emit(eventType, payload, taskId, ctx, opts) {
34
+ await appendEvent({
35
+ session_id: ctx.sessionId,
36
+ actor: ctx.actor ?? 'system',
37
+ event_type: eventType,
38
+ task_id: taskId,
39
+ plan_version: ctx.planVersion,
40
+ causal_parent: ctx.causalParent,
41
+ payload,
42
+ }, opts);
43
+ }
44
+ /**
45
+ * Canonical form for content-addressing a Result: object keys sorted at every
46
+ * depth, arrays left in the order they were written (an artifact list is a
47
+ * sequence, not a set). Two serializations of one Result must hash the same,
48
+ * or a round-trip through JSON would read as a second worker run.
49
+ */
50
+ function canonicalize(value) {
51
+ if (Array.isArray(value))
52
+ return value.map(canonicalize);
53
+ if (value !== null && typeof value === 'object') {
54
+ const source = value;
55
+ const out = {};
56
+ for (const key of Object.keys(source).sort())
57
+ out[key] = canonicalize(source[key]);
58
+ return out;
59
+ }
60
+ return value;
61
+ }
62
+ function resultHash(result) {
63
+ return createHash('sha256')
64
+ .update(JSON.stringify(canonicalize(result)))
65
+ .digest('hex');
66
+ }
67
+ /**
68
+ * D-23/P9-12 — emit the event the rest of the factory has been reading and
69
+ * nobody was writing.
70
+ *
71
+ * `task-result-recorded` is what agents-registry.ts closes a live agent on and
72
+ * what db/queries.ts's analytics() and tokensSpentByEpic() sum `token_usage`
73
+ * out of. Until now the only way one reached the log was an operator typing
74
+ * `smith event append`, so "agent still live" and "0 tokens spent" were not
75
+ * observations — they were a record of what someone remembered to write down.
76
+ * The gate is the right producer because it is the one component that has
77
+ * already proved the Result is schema-valid; emitting from the dispatcher
78
+ * would put unvalidated payloads into the rows budget accounting trusts.
79
+ *
80
+ * Emitted BEFORE the test gate runs and regardless of how the gate then rules:
81
+ * the worker's tokens were spent and its agent stopped running whether or not
82
+ * the tests it left behind pass. Deferring the event to a clean outcome would
83
+ * make the live-agent count wrong for exactly the tasks that go wrong.
84
+ *
85
+ * Deduped on a content hash rather than an event id, scoped to the session and
86
+ * task: `smith gate run` over an unchanged result.json is an operator
87
+ * re-reading their own gate, not a second worker run, and double-counting it
88
+ * would inflate the very budget numbers P9-17/P9-18 exist to make honest. A
89
+ * genuinely re-run worker differs somewhere — a new `token_usage` at the very
90
+ * least — and hashes differently, so it counts again.
91
+ *
92
+ * The dedup reads the lineage (D-119). "Scoped to the session" was the bug: an
93
+ * epic that continues in a fresh session re-gates the same result.json against
94
+ * an empty history, and the double count lands in the budget numbers P9-17/
95
+ * P9-18 exist to keep honest.
96
+ */
97
+ async function recordResult(result, taskId, ctx, opts) {
98
+ const hash = resultHash(result);
99
+ const events = await readLineageEvents(ctx.sessionId, opts);
100
+ for (const { record } of events) {
101
+ if (record.event_type !== TASK_RESULT_EVENT_TYPE)
102
+ continue;
103
+ // Matched, not compared: the same task reaches the log under both
104
+ // spellings — whoever appended a result stamped what they typed, and
105
+ // `smith gate run` stamps its own argument — and a raw `!==` makes the
106
+ // dedup miss its own task (D-183). The content hash below still has to
107
+ // agree, so a match here can only ever suppress a byte-identical Result.
108
+ if (!taskIdsMatch(record.task_id, taskId))
109
+ continue;
110
+ // Re-hashed from the stored payload, not carried on it: the payload is the
111
+ // Result verbatim (db/projector.ts reads it as one), and a hash field
112
+ // wedged in beside it would be a Result that no longer validates.
113
+ if (resultHash(record.payload) === hash)
114
+ return;
115
+ }
116
+ await emit(TASK_RESULT_EVENT_TYPE, result, taskId, ctx, opts);
117
+ }
118
+ /** A judge call is a network round-trip, not a test: generous enough that a reasoning model finishes, bounded so one hung provider can't hold the gate open. */
119
+ const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
120
+ /**
121
+ * crosscheck.yml `quorum_triggers`, restricted to the two the gate owns: "any
122
+ * S1/S2 finding, before it blocks a task" and "same-mistake findings
123
+ * (judgment.same-mistake)". These two are automatic — every intake evaluates
124
+ * them. The other two live in their own operator-invoked hosts and call
125
+ * runQuorumCase() the same way: epic-level final verdict in epic.ts
126
+ * (`smith epic verdict`) and the plan_quorum triggers in planQuorum.ts
127
+ * (`smith plan quorum`). See docs/runbooks/providers.md §2.
128
+ */
129
+ function quorumTriggerFor(decision) {
130
+ if (decision.sameMistake)
131
+ return 'same-mistake';
132
+ if (decision.blocks)
133
+ return 'blocking-finding';
134
+ return null;
135
+ }
136
+ /** null when no external provider is `enabled` — the shipped default, and the reason cross-checking costs exactly nothing until an operator turns it on. */
137
+ function resolveCrosscheck(input) {
138
+ const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
139
+ const providers = enabledExternalProviders(policy);
140
+ if (providers.length === 0)
141
+ return null;
142
+ return {
143
+ policy,
144
+ providers,
145
+ nativeProvider: nativeProviderName(policy),
146
+ budget: input.crosscheck?.budget ?? DEFAULT_JUDGE_BUDGET,
147
+ };
148
+ }
149
+ function quorumDecisionPayload(quorum, finding, trigger, finderProvider, blocks) {
150
+ const gating = quorum.gating;
151
+ return {
152
+ task_id: finding.task_id,
153
+ finding_id: finding.finding_id,
154
+ fingerprint: finding.fingerprint,
155
+ trigger_reason: trigger,
156
+ finder_provider: finderProvider,
157
+ outcome: gating.outcome,
158
+ decision: gating.outcome === 'decided' ? gating.decision : null,
159
+ agreement: gating.outcome === 'decided' ? gating.agreement : null,
160
+ gating_participants: gating.outcome === 'decided' ? gating.participants : [],
161
+ escalation_reason: gating.outcome === 'escalate' ? gating.reason : null,
162
+ rationales: gating.outcome === 'escalate' ? gating.rationales : [],
163
+ participants: quorum.participants.map((p) => ({
164
+ provider: p.provider,
165
+ mode: p.mode,
166
+ ok: p.ok,
167
+ verdict: p.verdict,
168
+ excluded_as_finder: p.excludedAsFinder,
169
+ })),
170
+ native_verdict: 'confirm',
171
+ /** What the gate actually did with the finding after the quorum spoke. */
172
+ blocks,
173
+ };
174
+ }
175
+ /**
176
+ * Run one quorum case for a finding that tripped a trigger, emit its
177
+ * `quorum-decision` event, and report whether the quorum overturned the
178
+ * finding. Escalations are appended to `escalations` (an operator-facing
179
+ * list) only when an ACTIVE external judge actually took part — a
180
+ * shadow-mode case always comes back `insufficient-providers` by
181
+ * construction and there is nothing there for an operator to arbitrate.
182
+ */
183
+ async function crossCheckFinding(args, ctx, opts, escalations) {
184
+ const { finding, trigger, crosscheck } = args;
185
+ // asymmetric_roles.finder_ne_critic: whoever raised the finding gets no
186
+ // vote on it. Unknown provenance reads as the native provider — the
187
+ // conservative default, since it removes a vote rather than adding one.
188
+ const finderProvider = finding.found_by_provider ?? crosscheck.nativeProvider;
189
+ const quorum = await runQuorumCase({
190
+ taskId: finding.task_id,
191
+ findingId: finding.finding_id,
192
+ triggerReason: trigger,
193
+ finderProvider,
194
+ kind: 'verify',
195
+ native: {
196
+ provider: crosscheck.nativeProvider,
197
+ verdict: 'confirm',
198
+ rationale: finding.summary,
199
+ },
200
+ providers: crosscheck.providers,
201
+ request: findingJudgeRequest(finding, args.filePath, crosscheck.budget),
202
+ policy: crosscheck.policy,
203
+ quorumPolicy: { minProviders: crosscheck.policy.quorumRule.minProviders },
204
+ fetchImpl: args.input.crosscheck?.fetchImpl,
205
+ }, ctx, opts);
206
+ const gating = quorum.gating;
207
+ const overturned = gating.outcome === 'decided' && gating.decision === 'refute';
208
+ const blocks = overturned ? false : args.decision.blocks;
209
+ if (gating.outcome === 'escalate') {
210
+ const hadActiveJudge = quorum.participants.some((p) => p.mode === 'active' && p.ok && !p.excludedAsFinder);
211
+ if (hadActiveJudge) {
212
+ escalations.push({
213
+ findingId: finding.finding_id,
214
+ triggerReason: trigger,
215
+ reason: gating.reason,
216
+ rationales: gating.rationales,
217
+ });
218
+ }
219
+ }
220
+ await emit('quorum-decision', quorumDecisionPayload(quorum, finding, trigger, finderProvider, blocks), finding.task_id, ctx, opts);
221
+ return overturned;
222
+ }
223
+ function isRecord(value) {
224
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
225
+ }
226
+ /**
227
+ * Read the grader's rubric result and let it gate (D-34/P9-14). The grader
228
+ * runs before the gates precisely so its verdict can inform them, but until
229
+ * now no code path opened the file — which is how two graders on the same
230
+ * template wrote two different shapes and nothing objected. A schema is
231
+ * load-bearing only if something loads it.
232
+ *
233
+ * The verdict lives at `.structured_output` of the result envelope the grader
234
+ * writes (.claude/agents/grader.md, "Output contract"); the envelope itself is
235
+ * a Result and is not re-checked here. Three ways to not pass: the file is not
236
+ * shaped like a grader result (`grader-invalid`), the run came back `dead`
237
+ * because the task spec had no checkable acceptance criteria (`grader-fail`,
238
+ * verdict `not-graded`), or the rubric says the criteria were not met
239
+ * (`grader-fail`).
240
+ */
241
+ async function checkGraderVerdict(verdictFile, taskId, ctx, opts, taxonomy, schemas) {
242
+ const emitVerdict = (payload) => emit('grader-verdict', {
243
+ run_status: null,
244
+ round: null,
245
+ overall: null,
246
+ failed_criteria: [],
247
+ errors: [],
248
+ ...payload,
249
+ }, taskId, ctx, opts);
250
+ if (!isRecord(verdictFile)) {
251
+ await emitVerdict({
252
+ verdict: 'invalid',
253
+ errors: [{ path: '/', message: 'grader verdict file is not a JSON object' }],
254
+ });
255
+ return { ok: false, reason: 'grader-invalid' };
256
+ }
257
+ const runStatus = verdictFile.run_status;
258
+ // `dead` is the grader's own word for "this task spec has no checkable
259
+ // acceptance criteria". Reported as its own verdict rather than as a missing
260
+ // `structured_output`, which is what it would otherwise look like.
261
+ if (runStatus === 'dead') {
262
+ await emitVerdict({ verdict: 'not-graded', run_status: runStatus });
263
+ return { ok: false, reason: 'grader-fail' };
264
+ }
265
+ const structured = verdictFile.structured_output;
266
+ if (!isRecord(structured)) {
267
+ await emitVerdict({
268
+ verdict: 'invalid',
269
+ run_status: typeof runStatus === 'string' ? runStatus : null,
270
+ errors: [
271
+ {
272
+ path: '/structured_output',
273
+ message: 'grader verdict must be at .structured_output (round, criteria, overall), per the grader Output contract',
274
+ },
275
+ ],
276
+ });
277
+ return { ok: false, reason: 'grader-invalid' };
278
+ }
279
+ const validation = validateRecord(schemas, taxonomy, 'grader-verdict', structured);
280
+ if (!validation.valid) {
281
+ await emitVerdict({
282
+ verdict: 'invalid',
283
+ run_status: typeof runStatus === 'string' ? runStatus : null,
284
+ errors: validation.errors,
285
+ });
286
+ return { ok: false, reason: 'grader-invalid' };
287
+ }
288
+ const criteria = structured.criteria;
289
+ // A `fail` or `partial` criterion under an overall `pass` is the grader
290
+ // contradicting itself, and the per-criterion verdict is the one carrying
291
+ // evidence — so it wins. `partial` is what the grader writes when it runs
292
+ // out of context mid-rubric: a criterion nobody finished grading is not met.
293
+ const failed = criteria.filter((c) => c.status !== 'pass');
294
+ const met = structured.overall === 'pass' && failed.length === 0;
295
+ await emitVerdict({
296
+ verdict: met ? 'met' : 'not-met',
297
+ run_status: typeof runStatus === 'string' ? runStatus : null,
298
+ round: structured.round,
299
+ overall: structured.overall,
300
+ failed_criteria: failed,
301
+ ...(Array.isArray(structured.gaps) ? { gaps: structured.gaps } : {}),
302
+ });
303
+ return met ? { ok: true } : { ok: false, reason: 'grader-fail' };
304
+ }
305
+ async function intakeAndDecide(input, ctx, opts) {
306
+ const decisions = [];
307
+ const blocking = [];
308
+ const pendingWaivers = [];
309
+ const escalations = [];
310
+ const reattributed = [];
311
+ const specFindings = [];
312
+ // Resolved at most once per gate run, and only if some finding actually
313
+ // trips a trigger — a task with no S1/S2 and no same-mistake finding never
314
+ // reads crosscheck.yml at all.
315
+ let crosscheck;
316
+ // Whose finding each one is, decided up front and written nowhere yet: a
317
+ // routing that turns out to belong to a waived or refuted finding is simply
318
+ // dropped, and mints no task for a bug nobody is going to fix.
319
+ const routings = await routeFindings(input.findingsInput, { defaultTaskId: input.taskId, ownership: input.ownership }, ctx, opts);
320
+ for (const routing of routings) {
321
+ const filePath = routing.input.filePath;
322
+ const raised = await raiseFinding(routing.input, ctx, opts);
323
+ if (raised.suppressed)
324
+ continue;
325
+ const decision = decide({ finding_category: raised.finding.finding_category, severity: raised.finding.severity }, {
326
+ filePath,
327
+ lessons: input.lessons,
328
+ corroboratedSeverity: input.corroboratedSeverities?.[raised.finding.fingerprint],
329
+ });
330
+ decisions.push({
331
+ fingerprint: raised.finding.fingerprint,
332
+ findingId: raised.finding.finding_id,
333
+ originalSeverity: raised.finding.severity,
334
+ findingCategory: raised.finding.finding_category,
335
+ filePath,
336
+ decision,
337
+ });
338
+ const effective = { ...raised.finding, severity: decision.severity };
339
+ const trigger = quorumTriggerFor(decision);
340
+ if (trigger) {
341
+ if (crosscheck === undefined)
342
+ crosscheck = resolveCrosscheck(input);
343
+ if (crosscheck !== null) {
344
+ const overturned = await crossCheckFinding({ finding: effective, filePath, trigger, decision, crosscheck, input }, ctx, opts, escalations);
345
+ // The one behavior change a quorum can make: a decided refute kills
346
+ // the finding outright, so it neither blocks nor spends a waiver.
347
+ // Everything else (decided confirm, either escalation, shadow mode)
348
+ // leaves the severity decision exactly as severity.ts made it.
349
+ if (overturned) {
350
+ await transition(effective.finding_id, 'refuted', ctx, opts);
351
+ continue;
352
+ }
353
+ }
354
+ }
355
+ // A spec finding says the code is right and the plan is wrong, so it
356
+ // leaves before attribution rather than after it: reattribution would
357
+ // hand it to whoever owns the file, or mint a follow-up task, and neither
358
+ // can contain the fix — the fix is a plan amendment (spec.ts's
359
+ // amendPlan). Blocking here instead is D-33, the deadlock this diverts
360
+ // around: the gated diff cannot legally change the criterion it violates.
361
+ if (findingScope(effective) === SPEC_FINDING_SCOPE) {
362
+ specFindings.push(effective);
363
+ continue;
364
+ }
365
+ // Only now — past the waiver check and past the quorum — is the finding
366
+ // real enough to change somebody else's world. A finding that belongs to
367
+ // another task never enters `blocking`: blocking here would stop a diff
368
+ // that cannot contain the fix, which is D-41 itself.
369
+ if (routing.attribution !== 'gated') {
370
+ await recordReattribution(routing, effective, ctx, opts);
371
+ reattributed.push({
372
+ findingId: effective.finding_id,
373
+ filePath,
374
+ taskId: routing.taskId,
375
+ attribution: routing.attribution,
376
+ reason: routing.reason,
377
+ });
378
+ continue;
379
+ }
380
+ if (decision.blocks)
381
+ blocking.push(effective);
382
+ else if (decision.action === 'waiver-batch')
383
+ pendingWaivers.push(effective);
384
+ }
385
+ await emit('severity-decisions', {
386
+ decisions: decisions.map((d) => ({
387
+ fingerprint: d.fingerprint,
388
+ finding_id: d.findingId,
389
+ original_severity: d.originalSeverity,
390
+ severity: d.decision.severity,
391
+ action: d.decision.action,
392
+ same_mistake: d.decision.sameMistake,
393
+ matched_lesson_id: d.decision.matchedLessonId,
394
+ finding_category: d.findingCategory,
395
+ file_path: d.filePath,
396
+ corroborated: d.decision.corroborated,
397
+ })),
398
+ // The instrument, recorded next to the reading. Every `same_mistake:
399
+ // false` above is conditional on the gate having held a lesson that
400
+ // could have said otherwise, and `--lessons` is optional (cli.ts): a
401
+ // gate run without it decides "no repeat" for every finding and used to
402
+ // leave no trace that it was blind. Without this count the same-mistake
403
+ // KPI cannot tell a clean run from an unequipped one, so it reports
404
+ // `unverifiable` for every intake missing it — including all the ones
405
+ // already on disk, deliberately.
406
+ lessons_loaded: input.lessons.length,
407
+ lessons_escalating: input.lessons.filter(canEscalate).length,
408
+ // The same argument one level finer, and for the same reason. A count
409
+ // tells `kpi same-mistake` whether the gate could detect a repeat at
410
+ // all; it cannot tell `lessons audit` whether THIS lesson was among the
411
+ // ones loaded, and "never fired" is only evidence against a lesson that
412
+ // was present to fire. Events written before this field say nothing
413
+ // about any individual entry, and the audit reads them as `unverifiable`
414
+ // rather than as a corpus of lessons that have earned deletion.
415
+ lesson_ids_escalating: input.lessons.filter(canEscalate).map((l) => l.lessonId),
416
+ }, input.taskId, ctx, opts);
417
+ return { blocking, pendingWaivers, escalations, reattributed, specFindings };
418
+ }
419
+ const BIN_DIR = path.join('node_modules', '.bin');
420
+ function isDirectory(target) {
421
+ try {
422
+ return statSync(target).isDirectory();
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ }
428
+ const DEFAULT_COVERAGE_CHECK = 'coverage';
429
+ /**
430
+ * The per-file numbers a coverage check produced, or null when this gate has
431
+ * no coverage check to speak for (D-40/P9-25).
432
+ *
433
+ * The subjects are the gated task's OWN literal claims. Not the whole plan's:
434
+ * a file another task owns is that task's gate's business, and blocking here
435
+ * for it is D-41 in a new costume. Not its globs either — `src/**` names a
436
+ * region, and a region has no single number a per-file criterion could cite.
437
+ *
438
+ * A table with no row for this task is not that task owning no file. The read
439
+ * used to spell both `[]`, and zero subjects reports `complete: true` — "0 of
440
+ * 0 named files have a per-file number" — so the gate passed by never having
441
+ * looked at anything, on exactly the runs where a plan WAS handed to it.
442
+ * `CoverageEvidence.complete` says what that is worth: "Evidence that omits
443
+ * the subject of the criterion is not evidence."
444
+ *
445
+ * The reachable spelling is the id form, not a typo. A plan lists
446
+ * `epic-1/task-1` and an operator types `task-1` — which is why plan.ts
447
+ * carries resolveTaskId ("Use the full id the plan lists"), and why cli.ts's
448
+ * budgetFromFlags resolves through it before reading the budget off the very
449
+ * same `--plan` file. This read compares raw strings, so on one `smith gate
450
+ * run` the budget check finds its task and coverage silently finds nobody. A
451
+ * `--plan` from the wrong epic reads the same way from in here.
452
+ *
453
+ * So it refuses, and cli.ts already wrote the rule for the dispatch side of
454
+ * the same read: "a `--plan` that names a task it does not contain is an error
455
+ * rather than an empty claims list, which would silently drop every claim-path
456
+ * lesson the task was supposed to see" (claimsForDispatch). Absent ownership
457
+ * stays what it was — a caller with no plan to hand is not forced to invent
458
+ * one, and gets the total with no subjects to narrow it.
459
+ *
460
+ * The plan is not the only register (D-48/P9-31). A follow-up minted by
461
+ * `findings raise` is real, gateable work that no plan version has been cut
462
+ * for yet — budgetFromFlags reports its budget as not-declared rather than
463
+ * refusing to gate it — and `gate run` has no `--claims` flag, so for such a
464
+ * task "use the id the plan lists" names no id that exists. Refusing on the
465
+ * plan alone would make every follow-up gate carrying a coverage check
466
+ * unpassable. `emitFollowUpTask` wrote its claims into the log, so the log is
467
+ * asked second, and only for the one id at the gate: it is a register of what
468
+ * was added, not a second ownership table, and folding every added task into
469
+ * the map would change whose finding is whose on gates that have nothing to
470
+ * do with follow-ups.
471
+ */
472
+ async function gatherCoverageEvidence(input, ctx, opts) {
473
+ const checkName = input.coverage?.checkName ?? DEFAULT_COVERAGE_CHECK;
474
+ if (!input.checks.some((c) => c.name === checkName))
475
+ return null;
476
+ const owner = input.ownership?.find((t) => t.task_id === input.taskId);
477
+ // Only when the plan came up empty, and only then: this fold reads the whole
478
+ // lineage, and the common gate is a planned task the table names on the
479
+ // first pass.
480
+ const registered = input.ownership !== undefined && owner === undefined
481
+ ? await readAddedTasks({ sessionId: ctx.sessionId }, opts)
482
+ : [];
483
+ const claims = owner?.claims ?? loggedClaims(registered.find((t) => t.taskId === input.taskId));
484
+ const evidence = await collectCoverageEvidence({
485
+ worktreeDir: input.worktreeDir,
486
+ claims: claims ?? [],
487
+ ...(input.coverage?.summaryPath ? { summaryPath: input.coverage.summaryPath } : {}),
488
+ });
489
+ if (input.ownership === undefined || claims !== undefined)
490
+ return evidence;
491
+ // Whatever the reporter did produce stays on the certificate: the hole is
492
+ // whose files to judge, not whether the run measured anything.
493
+ const known = [
494
+ ...new Set([
495
+ ...input.ownership.map((t) => t.task_id ?? '(unnamed)'),
496
+ ...registered.map((t) => t.taskId),
497
+ ]),
498
+ ].join(', ');
499
+ return {
500
+ ...evidence,
501
+ complete: false,
502
+ detail: `neither the ownership table handed to this gate nor the log's register of added tasks has readable claims for "${input.taskId}", so this task's coverage subjects cannot be read and zero subjects would report complete without measuring anything. Known task ids: ${known === '' ? '(none)' : known} — use the id the plan lists, or record the follow-up before gating it.`,
503
+ };
504
+ }
505
+ /**
506
+ * The claims a `task-added` introduced this id with, or undefined when the log
507
+ * does not name it or names it with something that is not a list of paths.
508
+ * `AddedTask.claims` is `unknown` because the payload is free-form, and a
509
+ * half-readable list is the same hole as no list: filtering it would hand the
510
+ * criterion a shorter subject set than the task declared and call that
511
+ * complete.
512
+ */
513
+ function loggedClaims(task) {
514
+ if (task === undefined || !Array.isArray(task.claims))
515
+ return undefined;
516
+ return task.claims.every((c) => typeof c === 'string') ? task.claims : undefined;
517
+ }
518
+ /**
519
+ * Does this worktree own the toolchain its checks are about to run?
520
+ *
521
+ * `git worktree add` does not bring `node_modules` with it — git never copies
522
+ * an ignored directory — and Node answers a missing dependency by walking UP
523
+ * the filesystem. A task worktree under the factory's own tree therefore
524
+ * finds the FACTORY's vitest and biome, runs those, and the gate reports
525
+ * their verdict as if it were the project's. Wave 3 shipped a green epic that
526
+ * way (P9-16d).
527
+ *
528
+ * The question asked is `node_modules/.bin`, not `node_modules`: a vite run
529
+ * creates `node_modules/.vite` by itself, and that directory is precisely
530
+ * what made wave 3's worktree look installed. A project that declares no
531
+ * dependencies has nothing to install and is not blocked — the check reports
532
+ * that it ran and found nothing to do, rather than staying silent.
533
+ */
534
+ export function checkWorktreeDeps(worktreeDir) {
535
+ let manifest;
536
+ try {
537
+ manifest = JSON.parse(readFileSync(path.join(worktreeDir, 'package.json'), 'utf8'));
538
+ }
539
+ catch (err) {
540
+ // A malformed package.json is a real problem, but it is not THIS problem,
541
+ // and blocking on it here would file a JSON syntax error under "you forgot
542
+ // to install". The schema and test gates downstream get to say it.
543
+ const why = err.code === 'ENOENT' ? 'has no' : 'has an unreadable';
544
+ return {
545
+ ok: true,
546
+ declaresDependencies: false,
547
+ detail: `${worktreeDir} ${why} package.json — nothing to install.`,
548
+ };
549
+ }
550
+ const counts = (section) => typeof section === 'object' && section !== null ? Object.keys(section).length : 0;
551
+ const declared = counts(manifest.dependencies) + counts(manifest.devDependencies);
552
+ if (declared === 0) {
553
+ return {
554
+ ok: true,
555
+ declaresDependencies: false,
556
+ detail: 'package.json declares no dependencies — nothing to install.',
557
+ };
558
+ }
559
+ if (isDirectory(path.join(worktreeDir, BIN_DIR))) {
560
+ return {
561
+ ok: true,
562
+ declaresDependencies: true,
563
+ detail: `${declared} dependencies declared and ${BIN_DIR} is present.`,
564
+ };
565
+ }
566
+ return {
567
+ ok: false,
568
+ declaresDependencies: true,
569
+ detail: `${declared} dependencies declared but ${BIN_DIR} is missing in ${worktreeDir}. ` +
570
+ 'Every check would resolve its binaries from a parent node_modules, so the ' +
571
+ 'result would describe some other tree. Run `pnpm install` in the worktree.',
572
+ };
573
+ }
574
+ /**
575
+ * The composed gate pipeline for one task: schemaCheck(result) -> commit
576
+ * certificate -> worktree deps -> outstanding judges -> grader verdict ->
577
+ * testgate -> intake(findings) -> severity decisions -> outcome. The cheap
578
+ * refusals run first on purpose: each one is a reason the stages below it
579
+ * would be scoring something other than the diff the merge queue will take.
580
+ * Every stage emits its own event before the next stage runs; the function
581
+ * returns a pure structured outcome and never dispatches anything itself
582
+ * (dispatch decisions — e.g. bouncing back to the coder — stay with the
583
+ * caller).
584
+ */
585
+ export async function runGate(input, ctx, opts = {}) {
586
+ const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
587
+ const schemaResult = validateRecord(schemas, taxonomy, 'result', input.result);
588
+ await emit('schema-check-result', { valid: schemaResult.valid, errors: schemaResult.valid ? [] : schemaResult.errors }, input.taskId, ctx, opts);
589
+ if (!schemaResult.valid) {
590
+ return finalize({
591
+ outcome: 'blocked',
592
+ taskId: input.taskId,
593
+ reason: 'schema-invalid',
594
+ testResult: null,
595
+ schemaErrors: schemaResult.errors,
596
+ blockingFindings: [],
597
+ artifactIssues: [],
598
+ }, ctx, opts);
599
+ }
600
+ // The worker's Result is a fact about a run that already happened, so it is
601
+ // recorded before the gate's own refusals — a task blocked for an uncommitted
602
+ // worktree or an owed judge still produced this Result, and the projector
603
+ // should see it (P9-12).
604
+ await recordResult(input.result, input.taskId, ctx, opts);
605
+ // Before the tests, for the same reason the schema check comes before them:
606
+ // a Result whose evidence cannot be opened is not worth spending a test run
607
+ // on, and the operator would rather be told which path than be told it late.
608
+ // Behind the record above, though: a task blocked for an artifact under /tmp
609
+ // still produced this Result, exactly as one blocked for an uncommitted
610
+ // worktree did (P9-12).
611
+ const artifacts = input.result.artifacts ?? [];
612
+ const artifactCheck = checkArtifacts(artifacts, {
613
+ taskId: input.taskId,
614
+ artifactsDir: input.artifactsDir,
615
+ });
616
+ await emit('artifact-check-result', {
617
+ ok: artifactCheck.ok,
618
+ checked: artifactCheck.checked,
619
+ home: artifactCheck.home,
620
+ issues: artifactCheck.issues,
621
+ }, input.taskId, ctx, opts);
622
+ if (!artifactCheck.ok) {
623
+ return finalize({
624
+ outcome: 'blocked',
625
+ taskId: input.taskId,
626
+ reason: 'artifacts-missing',
627
+ testResult: null,
628
+ schemaErrors: [],
629
+ blockingFindings: [],
630
+ artifactIssues: artifactCheck.issues,
631
+ }, ctx, opts);
632
+ }
633
+ // Before anything expensive runs: is there a commit here to score at all?
634
+ // The gate scores a worktree, the merge queue merges a branch — when those
635
+ // are two different things, every check below goes green against work the
636
+ // merge will never see (D-30 / P9-8).
637
+ const commitCheck = certifyCommit(input.worktreeDir, {
638
+ ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef }),
639
+ });
640
+ await emit('commit-check-result', {
641
+ certified: commitCheck.certified,
642
+ reason: commitCheck.reason,
643
+ head: commitCheck.head,
644
+ branch: commitCheck.branch,
645
+ dirty: commitCheck.dirty,
646
+ base_ref: commitCheck.baseRef,
647
+ base_sha: commitCheck.baseSha,
648
+ commits_ahead: commitCheck.commitsAhead,
649
+ }, input.taskId, ctx, opts);
650
+ if (!commitCheck.certified) {
651
+ return finalize({
652
+ outcome: 'blocked',
653
+ taskId: input.taskId,
654
+ reason: 'not-committed',
655
+ testResult: null,
656
+ schemaErrors: [],
657
+ blockingFindings: [],
658
+ artifactIssues: [],
659
+ commitCheck,
660
+ }, ctx, opts);
661
+ }
662
+ // Before anything is RUN in the worktree — the schema check only reads the
663
+ // result document, but everything after this point executes a command there.
664
+ // Behind the commit certificate for the same reason the tests are: a
665
+ // toolchain proved sound in a worktree the merge will never see proves
666
+ // nothing (P9-16d / D-30).
667
+ const deps = checkWorktreeDeps(input.worktreeDir);
668
+ await emit('deps-check-result', { ok: deps.ok, declares_dependencies: deps.declaresDependencies, detail: deps.detail }, input.taskId, ctx, opts);
669
+ if (!deps.ok) {
670
+ return finalize({
671
+ outcome: 'blocked',
672
+ taskId: input.taskId,
673
+ reason: 'deps-missing',
674
+ testResult: null,
675
+ schemaErrors: [],
676
+ blockingFindings: [],
677
+ artifactIssues: [],
678
+ commitCheck,
679
+ }, ctx, opts);
680
+ }
681
+ // D-31, D-20 / P9-11. Scoring a task while one of its judges is still owed
682
+ // is scoring an unknown: `--evidence` absent reads identically whether the
683
+ // security reviewer found nothing or died mid-turn without writing its file,
684
+ // and wave 3 produced five of the second kind in one wave. So the gate
685
+ // compares the two sets before it does anything expensive. A task that
686
+ // dispatched no judges through `smith judge dispatch` has an empty dispatch
687
+ // set and is unaffected — this refuses only where there is a promise on
688
+ // record to refuse against.
689
+ const outstanding = outstandingJudges(await readJudgeTurns(input.taskId, ctx, opts));
690
+ if (outstanding.length > 0) {
691
+ await emit('judges-outstanding', {
692
+ judges: outstanding.map((j) => ({
693
+ agent_role: j.role,
694
+ round: j.round,
695
+ declared_artifact: j.declaredArtifact,
696
+ })),
697
+ }, input.taskId, ctx, opts);
698
+ return finalize({
699
+ outcome: 'blocked',
700
+ taskId: input.taskId,
701
+ reason: 'judges-outstanding',
702
+ testResult: null,
703
+ schemaErrors: [],
704
+ blockingFindings: [],
705
+ artifactIssues: [],
706
+ commitCheck,
707
+ outstandingJudges: outstanding,
708
+ }, ctx, opts);
709
+ }
710
+ // Before the tests, not after: the grader has already run, and if its rubric
711
+ // says the criteria were not met the diff bounces whatever the suite says —
712
+ // so a full test run here is spent for nothing. It sits behind the commit
713
+ // certificate for the same reason the tests do: a rubric scored against a
714
+ // worktree the merge will never see is scored against nothing (D-30/D-34).
715
+ if (input.graderVerdict !== undefined) {
716
+ const grader = await checkGraderVerdict(input.graderVerdict, input.taskId, ctx, opts, taxonomy, schemas);
717
+ if (!grader.ok) {
718
+ return finalize({
719
+ outcome: 'blocked',
720
+ taskId: input.taskId,
721
+ reason: grader.reason,
722
+ testResult: null,
723
+ schemaErrors: [],
724
+ blockingFindings: [],
725
+ artifactIssues: [],
726
+ commitCheck,
727
+ }, ctx, opts);
728
+ }
729
+ }
730
+ // Before the testgate, not after: the runs that most need an economy record
731
+ // are the expensive ones that then failed, and a check gated behind green
732
+ // tests would never produce one for them. Behind the refusals above for the
733
+ // opposite reason — a task blocked for an uncommitted worktree or an owed
734
+ // judge has no diff worth measuring yet, and stamping one would put a
735
+ // number on work the merge queue will never see.
736
+ const budgetCheck = runBudgetCheck(input);
737
+ await emit('budget-check-result', { ...budgetCheck }, input.taskId, ctx, opts);
738
+ const budget = { budgetCheck };
739
+ const testResult = await runTestgate(input.checks, {
740
+ cwd: input.worktreeDir,
741
+ runAll: input.runAll ?? false,
742
+ timeoutMs: input.timeoutMs,
743
+ });
744
+ await emit('testgate-result', { pass: testResult.pass, results: testResult.results }, input.taskId, ctx, opts);
745
+ if (!testResult.pass) {
746
+ return finalize({
747
+ outcome: 'blocked',
748
+ taskId: input.taskId,
749
+ reason: 'tests-failed',
750
+ testResult,
751
+ schemaErrors: [],
752
+ blockingFindings: [],
753
+ artifactIssues: [],
754
+ commitCheck,
755
+ ...budget,
756
+ }, ctx, opts);
757
+ }
758
+ // After the tests pass, before the findings are judged: a green coverage
759
+ // check that cannot name a number for the file the criterion is about has
760
+ // not answered the criterion (D-40/P9-25).
761
+ const coverage = await gatherCoverageEvidence(input, ctx, opts);
762
+ const withCoverage = coverage === null ? {} : { coverageEvidence: coverage };
763
+ if (coverage !== null) {
764
+ await emit('coverage-evidence', {
765
+ summary_path: coverage.summaryPath,
766
+ present: coverage.present,
767
+ complete: coverage.complete,
768
+ files_measured: coverage.filesMeasured,
769
+ total: coverage.total,
770
+ subjects: coverage.subjects.map((s) => ({
771
+ path: s.path,
772
+ status: s.status,
773
+ lines_pct: s.coverage?.lines.pct ?? null,
774
+ statements_pct: s.coverage?.statements.pct ?? null,
775
+ functions_pct: s.coverage?.functions.pct ?? null,
776
+ branches_pct: s.coverage?.branches.pct ?? null,
777
+ })),
778
+ detail: coverage.detail,
779
+ }, input.taskId, ctx, opts);
780
+ if (!coverage.complete) {
781
+ return finalize({
782
+ outcome: 'blocked',
783
+ taskId: input.taskId,
784
+ reason: 'coverage-evidence',
785
+ testResult,
786
+ schemaErrors: [],
787
+ blockingFindings: [],
788
+ artifactIssues: [],
789
+ commitCheck,
790
+ ...budget,
791
+ ...withCoverage,
792
+ }, ctx, opts);
793
+ }
794
+ }
795
+ const { blocking, pendingWaivers, escalations, reattributed, specFindings } = await intakeAndDecide(input, ctx, opts);
796
+ // Reported on every outcome, not just the clean one: a task can block on its
797
+ // own findings AND have handed one to somebody else in the same run.
798
+ const moved = reattributed.length > 0 ? { reattributedFindings: reattributed } : {};
799
+ // Same rule, and the reason a spec finding is not simply dropped: the gate
800
+ // passes the diff and still has to say the plan is wrong, or a diverted
801
+ // finding reads as a clean run.
802
+ const diverted = specFindings.length > 0 ? { specFindings } : {};
803
+ // Unconditional, unlike `moved`/`diverted` above: absent has to keep meaning
804
+ // "intake never ran" (every block before this point returns early), so `[]`
805
+ // is the only way to say the quorum ran and settled everything it saw.
806
+ // Attached to the passing outcomes too, because an escalation is addressed to
807
+ // the operator and does not become settled just because the finding that
808
+ // raised it was waived, re-attributed, or diverted to the spec (D-201).
809
+ const unsettled = { quorumEscalations: escalations };
810
+ if (blocking.length > 0) {
811
+ return finalize({
812
+ outcome: 'blocked',
813
+ taskId: input.taskId,
814
+ reason: 'findings',
815
+ testResult,
816
+ schemaErrors: [],
817
+ blockingFindings: blocking,
818
+ artifactIssues: [],
819
+ commitCheck,
820
+ ...unsettled,
821
+ ...moved,
822
+ ...diverted,
823
+ ...budget,
824
+ ...withCoverage,
825
+ }, ctx, opts);
826
+ }
827
+ if (pendingWaivers.length > 0) {
828
+ return finalize({
829
+ outcome: 'pass-with-waivers-pending',
830
+ taskId: input.taskId,
831
+ testResult,
832
+ commitCheck,
833
+ pendingFindings: pendingWaivers,
834
+ ...unsettled,
835
+ ...moved,
836
+ ...diverted,
837
+ ...budget,
838
+ ...withCoverage,
839
+ }, ctx, opts);
840
+ }
841
+ return finalize({
842
+ outcome: 'pass',
843
+ taskId: input.taskId,
844
+ testResult,
845
+ commitCheck,
846
+ ...unsettled,
847
+ ...moved,
848
+ ...diverted,
849
+ ...budget,
850
+ ...withCoverage,
851
+ }, ctx, opts);
852
+ }
853
+ /** Real token spend off the already-schema-valid result, or undefined if absent. */
854
+ function tokensSpent(result) {
855
+ const usage = result?.token_usage;
856
+ return typeof usage?.total_tokens === 'number' ? usage.total_tokens : undefined;
857
+ }
858
+ /**
859
+ * Declared budget vs. measured spend, for one task. The check never blocks: an
860
+ * overrun is recorded on the outcome and in the event log, and the operator
861
+ * decides. Blocking a green, reviewed task on its budget would move D-29's
862
+ * "trade finishing for compliance" pressure off the agent and onto the gate,
863
+ * and an overrun is as often evidence that the plan under-estimated the task as
864
+ * that the task overran.
865
+ */
866
+ function runBudgetCheck(input) {
867
+ const tokensUsed = tokensSpent(input.result);
868
+ if (input.budget === undefined) {
869
+ return {
870
+ status: 'not-declared',
871
+ overruns: [],
872
+ ...(tokensUsed !== undefined && { tokensUsed }),
873
+ };
874
+ }
875
+ try {
876
+ const measured = measureDiff(input.worktreeDir, {
877
+ ...(input.baseRef !== undefined && { baseRef: input.baseRef }),
878
+ });
879
+ return {
880
+ status: 'checked',
881
+ overruns: checkTaskBudget({
882
+ budget: input.budget,
883
+ ...(tokensUsed !== undefined && { tokensUsed }),
884
+ diffLines: measured.diffLines,
885
+ }),
886
+ ...(tokensUsed !== undefined && { tokensUsed }),
887
+ diffLines: measured.diffLines,
888
+ baseRef: measured.baseRef,
889
+ excludedLines: measured.excludedLines,
890
+ ...(measured.unmeasuredFiles.length > 0 && { unmeasuredFiles: measured.unmeasuredFiles }),
891
+ };
892
+ }
893
+ catch (err) {
894
+ // The token half is still worth having, so the diff half failing degrades
895
+ // the check rather than cancelling it — but `diffLines` stays absent, since
896
+ // "0 lines" and "could not look" must not read the same.
897
+ return {
898
+ status: 'unmeasurable',
899
+ overruns: checkTaskBudget({
900
+ budget: input.budget,
901
+ ...(tokensUsed !== undefined && { tokensUsed }),
902
+ }),
903
+ ...(tokensUsed !== undefined && { tokensUsed }),
904
+ unmeasurableReason: err instanceof DiffstatError ? `${err.code}: ${err.message}` : String(err),
905
+ };
906
+ }
907
+ }
908
+ async function finalize(outcome, ctx, opts) {
909
+ await emit('gate-outcome', { outcome: outcome.outcome, reason: 'reason' in outcome ? outcome.reason : null }, outcome.taskId, ctx, opts);
910
+ return outcome;
911
+ }