@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,479 @@
1
+ // Independent finder (factory/policies/crosscheck.yml `independent_finder`).
2
+ //
3
+ // quorum.ts is subtractive by construction: its vocabulary is
4
+ // `confirm | refute`, a critic is handed one claim and told to kill it, and
5
+ // the strongest thing a quorum can do is drop a finding the native reviewer
6
+ // already raised. A bug the native reviewer's context never surfaced is
7
+ // therefore a bug no amount of cross-checking reaches -- nothing outside that
8
+ // context is ever asked to look.
9
+ //
10
+ // This module is the other direction. A finder from a different vendor reads
11
+ // the same diff in a fresh context and returns its own evidence; reconcile()
12
+ // matches the two lists and says, per finding, whether the two sides agree. A
13
+ // quorum can now RAISE: a corroborated finding takes the worse of the two
14
+ // severities under `severity_resolution: highest-wins`, and an
15
+ // independent-only finding is minted into the gate.
16
+ //
17
+ // Three rules the reconciliation keeps, each written out on the policy block
18
+ // itself and enforced here:
19
+ //
20
+ // 1. Silence never subtracts. A native finding the finder did not raise is
21
+ // `native-only` with effect `none`. The finder was asked to read a diff,
22
+ // not to answer that claim, so its silence is absence of evidence.
23
+ // 2. Same file and category with different wording is `co-located`, never
24
+ // merged and never auto-raised: nothing here can tell one bug described
25
+ // twice from two bugs in one function.
26
+ // 3. An independent-only finding is minted, not privileged. It enters the
27
+ // gate as an ordinary finding and, at S1/S2, meets the same
28
+ // `quorum_triggers` critic as any other.
29
+ //
30
+ // Shadow is honoured the way quorum.ts honours it, and twice: every entry is
31
+ // computed and recorded, and `applied` is false unless BOTH the block's own
32
+ // `mode` and the mode of the provider that raised it are `active`. Recording
33
+ // a reconciliation costs a judge call that has already happened; acting on
34
+ // one is a gate change, and shadow means no gate changes.
35
+ import { loadCrosscheckPolicy, providerModel, } from './crosscheck.js';
36
+ import { SmithError } from './errors.js';
37
+ import { appendEvent } from './events.js';
38
+ import { computeFingerprint, mintFindings, normalizeFilePath, } from './findings.js';
39
+ import { runJudge } from './providers/index.js';
40
+ import { nativeProviderName, recordJudgeRun, } from './quorum.js';
41
+ import { worseSeverity } from './severity.js';
42
+ export class CrossFindingError extends SmithError {
43
+ }
44
+ /** The event one reconciliation writes. A free-string event_type, like every other (event.schema.json). */
45
+ export const CROSS_FINDING_EVENT_TYPE = 'cross-finding-reconciled';
46
+ /** taxonomy.yml `agent` role an independent finder's evidence is attributed to: it is a reviewer, run by someone else. */
47
+ const FINDER_AGENT_ROLE = 'reviewer';
48
+ const EMPTY_COUNTS = Object.freeze({
49
+ corroborated: 0,
50
+ 'co-located': 0,
51
+ 'independent-only': 0,
52
+ 'native-only': 0,
53
+ });
54
+ /**
55
+ * The co-location key: normalized path plus category, as a two-element JSON
56
+ * array rather than a joined string. Both halves are prose from a third-party
57
+ * provider, and any separator character we picked could appear inside a
58
+ * category -- letting one finding collide with another's location. JSON
59
+ * escaping makes the two fields unmergeable.
60
+ */
61
+ function coLocationKey(filePath, category) {
62
+ return JSON.stringify([normalizeFilePath(filePath), category]);
63
+ }
64
+ /**
65
+ * Group one dispatch's evidence by fingerprint, so two providers raising the
66
+ * same bug is ONE entry naming both, not two entries racing each other into
67
+ * the gate.
68
+ *
69
+ * Severity across a group is the worst offered, regardless of
70
+ * `severity_resolution`: that setting arbitrates between the native side and
71
+ * the independent one, and has nothing to say about two independent providers
72
+ * who read the same code and disagreed. Taking the milder of two readings
73
+ * there is how an S1 quietly becomes an S3.
74
+ */
75
+ function groupIndependent(runs) {
76
+ const groups = new Map();
77
+ for (const run of runs) {
78
+ for (const evidence of run.evidence) {
79
+ const fingerprint = computeFingerprint({
80
+ filePath: evidence.file_path,
81
+ category: evidence.finding_category,
82
+ summary: evidence.summary,
83
+ });
84
+ const existing = groups.get(fingerprint);
85
+ if (existing) {
86
+ if (!existing.providers.includes(run.provider))
87
+ existing.providers.push(run.provider);
88
+ if (run.mode === 'active' && !existing.gatingProviders.includes(run.provider)) {
89
+ existing.gatingProviders.push(run.provider);
90
+ }
91
+ existing.severity = worseSeverity(existing.severity, evidence.severity);
92
+ continue;
93
+ }
94
+ groups.set(fingerprint, {
95
+ fingerprint,
96
+ filePath: normalizeFilePath(evidence.file_path),
97
+ category: evidence.finding_category,
98
+ summary: evidence.summary,
99
+ severity: evidence.severity,
100
+ providers: [run.provider],
101
+ gatingProviders: run.mode === 'active' ? [run.provider] : [],
102
+ first: { provider: run.provider, evidence },
103
+ });
104
+ }
105
+ }
106
+ return [...groups.values()];
107
+ }
108
+ /**
109
+ * Match the native reviewer's findings against an independent finder's and say
110
+ * what each match does. Pure: every effect it names is a recommendation until
111
+ * runIndependentFinder() writes it down and a caller acts on it.
112
+ *
113
+ * Accounting is linear, not quadratic: one entry per independent fingerprint,
114
+ * plus one per native finding nothing matched. A native finding named as a
115
+ * co-located counterpart is accounted for by that entry and does not also
116
+ * appear as `native-only` -- otherwise one finding would be reported twice,
117
+ * under two outcomes that mean different things.
118
+ */
119
+ export function reconcile(input) {
120
+ const nativeByFingerprint = new Map();
121
+ const nativeByLocation = new Map();
122
+ for (const finding of input.native) {
123
+ if (!nativeByFingerprint.has(finding.fingerprint)) {
124
+ nativeByFingerprint.set(finding.fingerprint, finding);
125
+ }
126
+ if (finding.file_path === undefined)
127
+ continue;
128
+ const key = coLocationKey(finding.file_path, finding.finding_category);
129
+ const bucket = nativeByLocation.get(key);
130
+ if (bucket)
131
+ bucket.push(finding);
132
+ else
133
+ nativeByLocation.set(key, [finding]);
134
+ }
135
+ const blockActive = input.policy.mode === 'active';
136
+ const entries = [];
137
+ const mintable = [];
138
+ const accounted = new Set();
139
+ for (const group of groupIndependent(input.independent)) {
140
+ const gating = blockActive && group.gatingProviders.length > 0;
141
+ const native = nativeByFingerprint.get(group.fingerprint);
142
+ if (native) {
143
+ accounted.add(native.fingerprint);
144
+ const resolved = input.policy.severityResolution === 'highest-wins'
145
+ ? worseSeverity(native.severity, group.severity)
146
+ : native.severity;
147
+ const raises = resolved !== native.severity;
148
+ entries.push({
149
+ outcome: 'corroborated',
150
+ fingerprint: group.fingerprint,
151
+ file_path: group.filePath,
152
+ finding_category: group.category,
153
+ // The native wording, because the native finding is the record that
154
+ // exists; the independent side corroborates it, it does not restate it.
155
+ summary: native.summary,
156
+ native_finding_id: native.finding_id,
157
+ native_severity: native.severity,
158
+ independent_severity: group.severity,
159
+ providers: group.providers,
160
+ gating_providers: group.gatingProviders,
161
+ effect: raises ? 'raise-severity' : 'none',
162
+ resolved_severity: raises ? resolved : null,
163
+ applied: raises && gating,
164
+ counterparts: [],
165
+ });
166
+ continue;
167
+ }
168
+ const coLocated = nativeByLocation.get(coLocationKey(group.filePath, group.category)) ?? [];
169
+ if (coLocated.length > 0) {
170
+ for (const finding of coLocated)
171
+ accounted.add(finding.fingerprint);
172
+ entries.push({
173
+ outcome: 'co-located',
174
+ fingerprint: group.fingerprint,
175
+ file_path: group.filePath,
176
+ finding_category: group.category,
177
+ summary: group.summary,
178
+ // No single native counterpart is named: co-location is a location
179
+ // match, and picking one of several would read as an identification.
180
+ // `counterparts` below carries all of them.
181
+ native_finding_id: null,
182
+ native_severity: null,
183
+ independent_severity: group.severity,
184
+ providers: group.providers,
185
+ gating_providers: group.gatingProviders,
186
+ effect: 'none',
187
+ resolved_severity: null,
188
+ applied: false,
189
+ counterparts: coLocated.map((f) => f.finding_id),
190
+ });
191
+ continue;
192
+ }
193
+ entries.push({
194
+ outcome: 'independent-only',
195
+ fingerprint: group.fingerprint,
196
+ file_path: group.filePath,
197
+ finding_category: group.category,
198
+ summary: group.summary,
199
+ native_finding_id: null,
200
+ native_severity: null,
201
+ independent_severity: group.severity,
202
+ providers: group.providers,
203
+ gating_providers: group.gatingProviders,
204
+ effect: 'raise-finding',
205
+ resolved_severity: null,
206
+ applied: gating,
207
+ counterparts: [],
208
+ });
209
+ if (gating)
210
+ mintable.push(group.first);
211
+ }
212
+ for (const finding of input.native) {
213
+ if (accounted.has(finding.fingerprint))
214
+ continue;
215
+ accounted.add(finding.fingerprint);
216
+ entries.push({
217
+ outcome: 'native-only',
218
+ fingerprint: finding.fingerprint,
219
+ file_path: finding.file_path === undefined ? null : normalizeFilePath(finding.file_path),
220
+ finding_category: finding.finding_category,
221
+ summary: finding.summary,
222
+ native_finding_id: finding.finding_id,
223
+ native_severity: finding.severity,
224
+ independent_severity: null,
225
+ providers: [],
226
+ gating_providers: [],
227
+ // Rule 1. The finder was never asked about this claim.
228
+ effect: 'none',
229
+ resolved_severity: null,
230
+ applied: false,
231
+ counterparts: [],
232
+ });
233
+ }
234
+ const counts = { ...EMPTY_COUNTS };
235
+ for (const entry of entries)
236
+ counts[entry.outcome] += 1;
237
+ const severityRaises = [];
238
+ for (const entry of entries) {
239
+ if (!entry.applied || entry.effect !== 'raise-severity')
240
+ continue;
241
+ if (entry.native_severity === null || entry.resolved_severity === null)
242
+ continue;
243
+ severityRaises.push({
244
+ fingerprint: entry.fingerprint,
245
+ from: entry.native_severity,
246
+ to: entry.resolved_severity,
247
+ });
248
+ }
249
+ return {
250
+ task_id: input.taskId,
251
+ mode: input.policy.mode,
252
+ severity_resolution: input.policy.severityResolution,
253
+ gates: entries.some((e) => e.applied),
254
+ counts,
255
+ entries,
256
+ severity_raises: severityRaises,
257
+ mintable,
258
+ };
259
+ }
260
+ /**
261
+ * Assemble the `review` JudgeRequest an independent finder answers.
262
+ *
263
+ * quorum.ts's findingJudgeRequest() deliberately carries the CLAIM and never
264
+ * the source. This one has nothing to send but the source: a finder with no
265
+ * diff has nothing to read, and a "find bugs in task X" prompt with no code
266
+ * produces confident findings about code that does not exist. So the diff IS
267
+ * the request, and every way of not having one is a refusal:
268
+ *
269
+ * - `send_diff: false` (the shipped default) is the operator's standing
270
+ * answer to "may worktree source leave this machine". No code in this repo
271
+ * has standing to answer that quietly.
272
+ * - an empty diff is refused rather than sent, per the paragraph above.
273
+ * - a diff over `max_diff_bytes` is refused rather than truncated. A finder
274
+ * cannot tell half a diff from a whole one, and reports what it was not
275
+ * shown as absent rather than as unread.
276
+ */
277
+ export function independentFinderRequest(input) {
278
+ if (!input.policy.sendDiff) {
279
+ throw new CrossFindingError('crossfind.diff-not-authorized', 'crosscheck.yml independent_finder.send_diff is false, so worktree source may not be sent to an external provider. There is no diffless fallback: a finder with no code invents findings. Set send_diff: true to authorize it.', { taskId: input.taskId });
280
+ }
281
+ if (input.diff.trim().length === 0) {
282
+ throw new CrossFindingError('crossfind.empty-diff', `Task "${input.taskId}" has an empty diff (${input.diffRef}). A finder prompted with no code returns findings about code it imagined.`, { taskId: input.taskId, diffRef: input.diffRef });
283
+ }
284
+ const bytes = Buffer.byteLength(input.diff, 'utf8');
285
+ if (bytes > input.policy.maxDiffBytes) {
286
+ throw new CrossFindingError('crossfind.diff-too-large', `Task "${input.taskId}" has a ${bytes}-byte diff, over independent_finder.max_diff_bytes (${input.policy.maxDiffBytes}). Refused rather than truncated: half a diff produces confident findings about code that is not there.`, { taskId: input.taskId, bytes, maxDiffBytes: input.policy.maxDiffBytes });
287
+ }
288
+ const prompt = [
289
+ 'You are an independent code reviewer in an automated review gate.',
290
+ 'Another reviewer has already read this diff. You have not been shown its',
291
+ 'findings and you will not be: your value here is the fresh context, so',
292
+ 'anything you do not raise is a bug nobody else is going to catch.',
293
+ '',
294
+ 'Your mandate is to FIND. Report only defects you can state as a concrete',
295
+ 'failure -- given these inputs, this is expected, this happens instead. A',
296
+ 'finding you cannot write a failure scenario for is not a finding, and a',
297
+ 'style preference is not a defect.',
298
+ '',
299
+ `Task: ${input.taskId}`,
300
+ `Diff: ${input.diffRef}`,
301
+ ...(input.criteria && input.criteria.length > 0
302
+ ? ['', 'Acceptance criteria this task is held to:', ...input.criteria.map((c) => ` - ${c}`)]
303
+ : []),
304
+ '',
305
+ 'Diff under review:',
306
+ '```diff',
307
+ input.diff,
308
+ '```',
309
+ '',
310
+ 'Return ONLY a JSON array matching finding-evidence.schema.json. One element',
311
+ 'per finding:',
312
+ '[{"file_path": "<repo-relative path>", "finding_category": "<taxonomy value>",',
313
+ ' "severity": "<taxonomy value>", "summary": "<one sentence>",',
314
+ ' "failure_scenario": {"inputs": "...", "expected": "...", "actual": "..."}}]',
315
+ '',
316
+ 'Return [] if you find nothing. An empty array is a real answer; an invented',
317
+ 'finding is not. Do not return finding_id, task_id, fingerprint,',
318
+ 'finding_status or found_by -- those are not yours to assign, and an answer',
319
+ 'carrying them is rejected unread.',
320
+ ].join('\n');
321
+ return {
322
+ kind: 'review',
323
+ taskId: input.taskId,
324
+ inputRefs: { diff_ref: input.diffRef, diff_bytes: String(bytes) },
325
+ prompt,
326
+ schemaName: 'finding-evidence',
327
+ budget: input.budget,
328
+ };
329
+ }
330
+ /**
331
+ * Invoke every provider `independent_finder.providers` names, record each run
332
+ * the way quorum.ts records a judge run (one `dispatch_decision` plus one
333
+ * chained `judge-verdict`, so a finder appears in `smith stats providers`
334
+ * alongside every other judge), reconcile what came back against the native
335
+ * findings, and write one `cross-finding-reconciled` event.
336
+ *
337
+ * Minting stops at `raise`: this returns the drafts and never calls
338
+ * raiseFinding(). Which findings actually enter a gate is the caller's
339
+ * decision, and a function that both judges and admits leaves no seam for an
340
+ * operator to stand in.
341
+ *
342
+ * Every way of running nothing throws rather than returning an empty report.
343
+ * "No provider ran" and "the finder found nothing" serialize to nearly the
344
+ * same JSON and mean opposite things, and the first one reads as a clean bill
345
+ * of health.
346
+ */
347
+ export async function runIndependentFinder(input, ctx, opts = {}) {
348
+ const policy = input.policy ?? loadCrosscheckPolicy();
349
+ const finder = policy.independentFinder;
350
+ if (!finder.enabled) {
351
+ throw new CrossFindingError('crossfind.disabled', 'crosscheck.yml independent_finder.enabled is false, so no finder ran. That is not the same answer as "no findings".', { taskId: input.taskId });
352
+ }
353
+ // Before the native check, because "names nobody" is not a wrong name -- and
354
+ // before the loop, because an empty list reaches the bottom of it having
355
+ // skipped no one, where the "named them, none are enabled" sentence renders
356
+ // with a hole where the operator's own words belong. Two mistakes, two
357
+ // repairs: add a name here, or enable one over in `providers:`.
358
+ if (finder.providers.length === 0) {
359
+ throw new CrossFindingError('crossfind.no-providers', "crosscheck.yml independent_finder.enabled is true but independent_finder.providers is empty, so there was nobody to ask and no finder ran. Name at least one external provider from this file's `providers:` map. There is no default: which vendors this box can reach is something only the operator knows.", { taskId: input.taskId });
360
+ }
361
+ const nativeName = nativeProviderName(policy);
362
+ if (finder.providers.includes(nativeName)) {
363
+ throw new CrossFindingError('crossfind.native-finder', `crosscheck.yml independent_finder.providers names "${nativeName}", the provider running the factory itself. A second opinion from the same model is one opinion billed twice.`, { provider: nativeName });
364
+ }
365
+ // What the native side is on record as saying about this diff AT ALL --
366
+ // "there are bugs here" or "there are none". recordJudgeRun() compares each
367
+ // provider's derived verdict against it to fill `agreement_with_native`, and
368
+ // for a finder that is the only honest reading of agreement: both sides
369
+ // answered the same question, neither answered one about the other's claim.
370
+ // `findingId` stays unset for the same reason -- a finder judges no single
371
+ // finding.
372
+ const nativeVerdict = {
373
+ provider: nativeName,
374
+ verdict: input.native.length > 0 ? 'confirm' : 'refute',
375
+ rationale: input.native.length > 0
376
+ ? `${input.native.length} native finding(s) raised.`
377
+ : 'No native findings raised.',
378
+ };
379
+ const runs = [];
380
+ const independent = [];
381
+ const skipped = [];
382
+ for (const providerName of finder.providers) {
383
+ const config = policy.providers[providerName];
384
+ if (!config || config.kind === 'native' || !config.enabled) {
385
+ skipped.push(providerName);
386
+ continue;
387
+ }
388
+ let outcome;
389
+ try {
390
+ const result = await runJudge(providerName, input.request, {
391
+ policy,
392
+ fetchImpl: input.fetchImpl,
393
+ });
394
+ outcome = { ok: true, result };
395
+ }
396
+ catch (err) {
397
+ outcome = {
398
+ ok: false,
399
+ error: {
400
+ code: err instanceof SmithError ? err.code : 'provider.unknown-error',
401
+ message: err instanceof Error ? err.message : String(err),
402
+ },
403
+ };
404
+ }
405
+ const run = { provider: providerName, mode: config.mode, outcome };
406
+ runs.push(run);
407
+ if (outcome.ok) {
408
+ // Already validated element-wise against finding-evidence.schema.json by
409
+ // the transport (providers/schema-validate.ts), taxonomy values and all,
410
+ // so a non-canonical severity arrives here as a failed run rather than as
411
+ // evidence mintFindings() would have to reject a second time. The
412
+ // Array.isArray guard is for the type, not the contract.
413
+ const output = outcome.result.output;
414
+ independent.push({
415
+ provider: providerName,
416
+ mode: config.mode,
417
+ evidence: (Array.isArray(output) ? output : []),
418
+ });
419
+ }
420
+ await recordJudgeRun({
421
+ taskId: input.taskId,
422
+ modelTier: config.modelTier,
423
+ model: providerModel(config),
424
+ kind: 'review',
425
+ run,
426
+ native: nativeVerdict,
427
+ }, ctx, opts);
428
+ }
429
+ if (runs.length === 0) {
430
+ throw new CrossFindingError('crossfind.no-providers', `independent_finder.providers names ${finder.providers.map((p) => `"${p}"`).join(', ')}, and crosscheck.yml enables none of them as an external provider. No finder ran.`, { taskId: input.taskId, skipped });
431
+ }
432
+ const report = reconcile({
433
+ taskId: input.taskId,
434
+ native: input.native,
435
+ independent,
436
+ policy: finder,
437
+ });
438
+ // Grouped by provider because `found_by_provider` is one field on one
439
+ // finding: a batch minted under the wrong name credits a vendor for evidence
440
+ // it never produced, and provider calibration reads that field.
441
+ const raise = [];
442
+ const byProvider = new Map();
443
+ for (const item of report.mintable) {
444
+ const bucket = byProvider.get(item.provider);
445
+ if (bucket)
446
+ bucket.push(item.evidence);
447
+ else
448
+ byProvider.set(item.provider, [item.evidence]);
449
+ }
450
+ for (const [provider, evidence] of byProvider) {
451
+ raise.push(...mintFindings(evidence, { taskId: input.taskId, foundBy: FINDER_AGENT_ROLE, foundByProvider: provider }, opts));
452
+ }
453
+ const reconciled = await appendEvent({
454
+ session_id: ctx.sessionId,
455
+ actor: ctx.actor ?? 'system',
456
+ event_type: CROSS_FINDING_EVENT_TYPE,
457
+ task_id: input.taskId,
458
+ plan_version: ctx.planVersion,
459
+ causal_parent: ctx.causalParent,
460
+ payload: {
461
+ task_id: input.taskId,
462
+ mode: report.mode,
463
+ severity_resolution: report.severity_resolution,
464
+ gates: report.gates,
465
+ counts: report.counts,
466
+ providers: runs.map((r) => r.provider),
467
+ // Both recorded, and separately: a provider crosscheck.yml never
468
+ // enabled and one that was dispatched and failed are the same absence
469
+ // in the entries and different facts about the run.
470
+ skipped_providers: skipped,
471
+ failed_providers: runs.filter((r) => !r.outcome.ok).map((r) => r.provider),
472
+ entries: report.entries,
473
+ severity_raises: report.severity_raises,
474
+ // Drafted, not raised: these ids exist only if the caller admits them.
475
+ mintable_finding_ids: raise.map((r) => r.finding.finding_id),
476
+ },
477
+ }, opts);
478
+ return { report, runs, raise, reconciledEventId: reconciled.event_id };
479
+ }