@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.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- 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
|
+
}
|