@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,263 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { collectCommittedChanges } from './claims.js';
|
|
3
|
+
import { certifyCommit, UNCOMMITTED_WORK_CODE } from './commit.js';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { runGit } from './git.js';
|
|
6
|
+
import { topoSort } from './graph.js';
|
|
7
|
+
import { buildSymbolGraph, collectSources } from './symbols.js';
|
|
8
|
+
import { emitTaskBlocked, emitWaveMerged } from './taskEvents.js';
|
|
9
|
+
import { renderSelectedTestCmd, selectTests } from './testSelect.js';
|
|
10
|
+
import { integrationBranchName } from './worktree.js';
|
|
11
|
+
export class QueueError extends SmithError {
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Admission order for a serial merge queue: topological by dependency
|
|
15
|
+
* edges, tie-broken by task id. Does not select which tasks to run — only
|
|
16
|
+
* orders whatever set the caller passes in.
|
|
17
|
+
*/
|
|
18
|
+
export function admit(tasks, edges = []) {
|
|
19
|
+
const result = topoSort(tasks.map((t) => t.task_id), edges);
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
throw new QueueError('queue.cyclic-dependency', `Cannot admit tasks with a cyclic dependency: ${result.cycle.join(', ')}.`, { cycle: result.cycle });
|
|
22
|
+
}
|
|
23
|
+
return result.order;
|
|
24
|
+
}
|
|
25
|
+
const OUTPUT_TAIL_LINES = 50;
|
|
26
|
+
function tailLines(text, n) {
|
|
27
|
+
const lines = text.split('\n');
|
|
28
|
+
return lines.slice(Math.max(0, lines.length - n)).join('\n');
|
|
29
|
+
}
|
|
30
|
+
function conflictingFiles(worktreeDir) {
|
|
31
|
+
const out = runGit(worktreeDir, ['diff', '--name-only', '--diff-filter=U']);
|
|
32
|
+
return out
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((f) => f.trim())
|
|
35
|
+
.filter((f) => f.length > 0);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Serial merge-queue admission step for one task: rebase its branch onto the
|
|
39
|
+
* current epic integration head, run the epic's test command, then
|
|
40
|
+
* merge --no-ff into the integration branch. Never auto-resolves a conflict
|
|
41
|
+
* and never touches main — the caller decides what happens next on a
|
|
42
|
+
* non-merged outcome.
|
|
43
|
+
*/
|
|
44
|
+
export async function step(task, opts) {
|
|
45
|
+
const integrationBranch = integrationBranchName(opts.epic);
|
|
46
|
+
const events = opts.events;
|
|
47
|
+
/**
|
|
48
|
+
* Log the outcome the queue just observed — a refusal it has already decided
|
|
49
|
+
* on, or a failure it has already seen. Never a prediction about work still
|
|
50
|
+
* ahead of it: an event written before the merge that the merge then
|
|
51
|
+
* falsifies is exactly the phantom row D-46 is about.
|
|
52
|
+
*/
|
|
53
|
+
const logBlocked = async (error, detail) => {
|
|
54
|
+
if (!events)
|
|
55
|
+
return;
|
|
56
|
+
const { ctx, ...opt } = events;
|
|
57
|
+
await emitTaskBlocked(task.taskId, { error, severity: 'S2-major', detail }, ctx, opt);
|
|
58
|
+
};
|
|
59
|
+
// Before the rebase, because a rebase, a test run and a merge all "succeed"
|
|
60
|
+
// against a branch that carries nothing: certify that there is a commit to
|
|
61
|
+
// merge at all (D-30/P9-8). A task that merges nothing is a bug, not a pass.
|
|
62
|
+
const commit = certifyCommit(task.worktreeDir, { baseRef: integrationBranch });
|
|
63
|
+
if (!commit.certified) {
|
|
64
|
+
const reason = commit.reason;
|
|
65
|
+
await logBlocked(reason === 'not-a-git-worktree' ? 'execution.env-failure' : UNCOMMITTED_WORK_CODE, commit.dirty.length > 0
|
|
66
|
+
? `${task.branch} has uncommitted work: ${commit.dirty.join(', ')}`
|
|
67
|
+
: `${task.branch} has nothing to merge into ${integrationBranch} (${reason}).`);
|
|
68
|
+
return { outcome: 'nothing-to-merge', taskId: task.taskId, reason, dirty: commit.dirty };
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
execFileSync('git', ['rebase', integrationBranch], {
|
|
72
|
+
cwd: task.worktreeDir,
|
|
73
|
+
stdio: 'pipe',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
const files = conflictingFiles(task.worktreeDir);
|
|
78
|
+
execFileSync('git', ['rebase', '--abort'], { cwd: task.worktreeDir, stdio: 'pipe' });
|
|
79
|
+
await logBlocked('integration.merge-conflict-textual', `Rebase onto ${integrationBranch} conflicted in: ${files.join(', ')}`);
|
|
80
|
+
return { outcome: 'rebase-conflict', taskId: task.taskId, conflictingFiles: files };
|
|
81
|
+
}
|
|
82
|
+
const plan = planTestRun(task.worktreeDir, opts);
|
|
83
|
+
const testOutcome = runTestCmd(plan.cmd, task.worktreeDir);
|
|
84
|
+
if (!testOutcome.passed) {
|
|
85
|
+
const outputTail = tailLines(testOutcome.output, OUTPUT_TAIL_LINES);
|
|
86
|
+
await logBlocked('execution.test-failure', outputTail);
|
|
87
|
+
return {
|
|
88
|
+
outcome: 'tests-failed',
|
|
89
|
+
taskId: task.taskId,
|
|
90
|
+
outputTail,
|
|
91
|
+
...(plan.report ? { tests: plan.report } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
execFileSync('git', ['checkout', integrationBranch], { cwd: opts.projectDir, stdio: 'pipe' });
|
|
95
|
+
execFileSync('git', ['merge', '--no-ff', task.branch, '-m', `Merge ${task.taskId} into ${integrationBranch}`], { cwd: opts.projectDir, stdio: 'pipe' });
|
|
96
|
+
if (events) {
|
|
97
|
+
const { ctx, ...opt } = events;
|
|
98
|
+
await emitWaveMerged(task.taskId, ctx, opt, mergedFiles(opts.projectDir));
|
|
99
|
+
}
|
|
100
|
+
return { outcome: 'merged', taskId: task.taskId, ...(plan.report ? { tests: plan.report } : {}) };
|
|
101
|
+
}
|
|
102
|
+
/** The commit `ref` names, or null when it names nothing (or nothing committish). */
|
|
103
|
+
function resolveCommit(projectDir, ref) {
|
|
104
|
+
try {
|
|
105
|
+
return runGit(projectDir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isAncestor(projectDir, ancestor, descendant) {
|
|
112
|
+
try {
|
|
113
|
+
runGit(projectDir, ['merge-base', '--is-ancestor', ancestor, descendant]);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Record a merge that landed outside the queue — after verifying it did (D-137).
|
|
122
|
+
*
|
|
123
|
+
* `queue run` refuses to log a merge under an id the plan does not contain, and
|
|
124
|
+
* that guard is right. What it left was a hole with no floor: `envkit-mcp-followup`
|
|
125
|
+
* was merged by hand, so four tasks were genuinely on the integration branch and
|
|
126
|
+
* the log held zero `wave-merged` events — the only event db/projector.ts folds to
|
|
127
|
+
* `completed`. The epic could not be closed by any honest means, which is a
|
|
128
|
+
* standing invitation to `event append` one by hand.
|
|
129
|
+
*
|
|
130
|
+
* So this verb takes the claim and checks it against the repository, in the order
|
|
131
|
+
* a reader would: the branch and the commit exist, the commit is a merge, the
|
|
132
|
+
* merge is on the integration branch, the branch head is one of its parents, and
|
|
133
|
+
* the branch carried something the merge could have landed. Only then does it
|
|
134
|
+
* write the same `wave-merged` the queue would have written.
|
|
135
|
+
*
|
|
136
|
+
* Refusals throw. They deliberately do NOT log an `error-logged`: a
|
|
137
|
+
* non-coordination one folds the task to `blocked`, and a task whose adopt
|
|
138
|
+
* command was mistyped is not blocked — that would put a wrong status in the
|
|
139
|
+
* table to record a wrong command line.
|
|
140
|
+
*/
|
|
141
|
+
export async function adopt(task, opts) {
|
|
142
|
+
const { projectDir } = opts;
|
|
143
|
+
const integrationBranch = integrationBranchName(opts.epic);
|
|
144
|
+
for (const ref of [integrationBranch, task.branch]) {
|
|
145
|
+
if (!resolveCommit(projectDir, ref)) {
|
|
146
|
+
throw new QueueError('queue.adopt-unknown-branch', `Branch "${ref}" does not exist in ${projectDir} — there is nothing to adopt against.`, { branch: ref, taskId: task.taskId });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const branchHead = resolveCommit(projectDir, task.branch);
|
|
150
|
+
const merge = resolveCommit(projectDir, opts.mergeCommit);
|
|
151
|
+
if (!merge) {
|
|
152
|
+
throw new QueueError('queue.adopt-unknown-commit', `"${opts.mergeCommit}" does not name a commit in ${projectDir}.`, { mergeCommit: opts.mergeCommit, taskId: task.taskId });
|
|
153
|
+
}
|
|
154
|
+
// `rev-list --parents -n 1 <sha>` prints the commit then its parents.
|
|
155
|
+
const parents = runGit(projectDir, ['rev-list', '--parents', '-n', '1', merge])
|
|
156
|
+
.split(/\s+/)
|
|
157
|
+
.slice(1)
|
|
158
|
+
.filter((p) => p.length > 0);
|
|
159
|
+
if (parents.length < 2) {
|
|
160
|
+
throw new QueueError('queue.adopt-not-a-merge', `Commit ${merge} has ${parents.length} parent(s), so it is not a merge. Name the merge commit that brought "${task.branch}" onto ${integrationBranch}.`, { mergeCommit: merge, taskId: task.taskId });
|
|
161
|
+
}
|
|
162
|
+
if (!isAncestor(projectDir, merge, integrationBranch)) {
|
|
163
|
+
throw new QueueError('queue.adopt-not-on-integration', `Merge ${merge} is not reachable from ${integrationBranch}. A merge the epic's integration branch does not contain landed nothing for this epic.`, { mergeCommit: merge, integrationBranch, taskId: task.taskId });
|
|
164
|
+
}
|
|
165
|
+
// Parent identity, not ancestry, and on purpose: a branch that grew after the
|
|
166
|
+
// hand-merge did not land everything it now carries, and adopting it would
|
|
167
|
+
// mark work completed that is not on the integration branch at all.
|
|
168
|
+
if (!parents.includes(branchHead)) {
|
|
169
|
+
throw new QueueError('queue.adopt-branch-not-merged', `Merge ${merge} has no parent equal to the head of "${task.branch}" (${branchHead}). Either the merge did not bring in this branch, or the branch has moved on since — in which case what it carries now has not landed.`, { mergeCommit: merge, branch: task.branch, branchHead, parents, taskId: task.taskId });
|
|
170
|
+
}
|
|
171
|
+
// D-30 restated for the merge commit: a branch cut and never committed to is
|
|
172
|
+
// the first parent of every merge made after it, so "is a parent" on its own
|
|
173
|
+
// would adopt a task that landed nothing.
|
|
174
|
+
const landed = Number(runGit(projectDir, ['rev-list', '--count', `${parents[0]}..${branchHead}`]));
|
|
175
|
+
if (!landed) {
|
|
176
|
+
throw new QueueError('queue.adopt-nothing-landed', `"${task.branch}" carries no commit that merge ${merge} could have landed — it is that merge's own base. A task that merged nothing did not finish (D-30).`, { mergeCommit: merge, branch: task.branch, taskId: task.taskId });
|
|
177
|
+
}
|
|
178
|
+
const filesChanged = mergedFiles(projectDir, merge);
|
|
179
|
+
if (opts.events) {
|
|
180
|
+
const { ctx, ...opt } = opts.events;
|
|
181
|
+
await emitWaveMerged(task.taskId, ctx, opt, filesChanged);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
outcome: 'adopted',
|
|
185
|
+
taskId: task.taskId,
|
|
186
|
+
mergeCommit: merge,
|
|
187
|
+
filesChanged: filesChanged ?? [],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The files the merge brought in, read off the merge commit itself (P9-15): the
|
|
192
|
+
* queue is the only component that knows which files a landed branch actually
|
|
193
|
+
* rewrote, and an open finding anchored to one of them is evidence about code
|
|
194
|
+
* that no longer exists.
|
|
195
|
+
*
|
|
196
|
+
* `<merge>^1..<merge>` — the first parent is the integration branch as it was a
|
|
197
|
+
* moment before, so this is exactly "what this merge changed" and not "what the
|
|
198
|
+
* task branch diverged by". A failure returns undefined rather than throwing:
|
|
199
|
+
* the merge has already landed, and losing the file list degrades the
|
|
200
|
+
* staleness check to its claims fallback instead of failing a queue step.
|
|
201
|
+
*/
|
|
202
|
+
function mergedFiles(projectDir, merge = 'HEAD') {
|
|
203
|
+
try {
|
|
204
|
+
const out = execFileSync('git', ['diff', '--name-only', `${merge}^1`, merge], {
|
|
205
|
+
cwd: projectDir,
|
|
206
|
+
encoding: 'utf8',
|
|
207
|
+
});
|
|
208
|
+
return out
|
|
209
|
+
.split('\n')
|
|
210
|
+
.map((f) => f.trim())
|
|
211
|
+
.filter((f) => f.length > 0);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Decide what to run. Any failure to build the graph — an unreadable worktree,
|
|
219
|
+
* a git call that did not answer — falls back to the operator's full command
|
|
220
|
+
* with the reason attached, because a test gate that skips on error is not a
|
|
221
|
+
* gate.
|
|
222
|
+
*/
|
|
223
|
+
function planTestRun(worktreeDir, opts) {
|
|
224
|
+
const template = opts.selectTestCmd;
|
|
225
|
+
// No template means selection was never asked for, so there is nothing to
|
|
226
|
+
// report: `tests` present in a result means selection ran, and its absence
|
|
227
|
+
// means the operator's full command was the only command there ever was.
|
|
228
|
+
if (template === undefined)
|
|
229
|
+
return { cmd: opts.testCmd };
|
|
230
|
+
try {
|
|
231
|
+
const changed = collectCommittedChanges(worktreeDir);
|
|
232
|
+
const graph = buildSymbolGraph(collectSources(worktreeDir));
|
|
233
|
+
const selection = selectTests(graph, changed);
|
|
234
|
+
if (selection.status === 'selected') {
|
|
235
|
+
return {
|
|
236
|
+
cmd: renderSelectedTestCmd(template, selection.tests),
|
|
237
|
+
report: { mode: 'selected', ran: selection.tests, known: selection.allTests.length },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
cmd: opts.testCmd,
|
|
242
|
+
report: { mode: 'full', known: selection.allTests.length, reasons: selection.reasons },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
247
|
+
return {
|
|
248
|
+
cmd: opts.testCmd,
|
|
249
|
+
report: { mode: 'full', reasons: [`selection failed: ${message}`] },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function runTestCmd(testCmd, cwd) {
|
|
254
|
+
try {
|
|
255
|
+
const output = execFileSync(testCmd, { cwd, shell: true, encoding: 'utf8', stdio: 'pipe' });
|
|
256
|
+
return { passed: true, output };
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
const e = err;
|
|
260
|
+
const output = `${e.stdout ?? ''}${e.stderr ?? ''}` || e.message;
|
|
261
|
+
return { passed: false, output };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Quorum engine (architecture §6 "Diversity quorum" + "Asymmetric roles,
|
|
2
|
+
// kill mandate"; factory/policies/crosscheck.yml `quorum_rule`/
|
|
3
|
+
// `asymmetric_roles`). Given the native (Claude) verdict plus zero or more
|
|
4
|
+
// external JudgeResults for one trigger case (crosscheck.yml
|
|
5
|
+
// `quorum_triggers`: a blocking S1/S2 finding, an epic-final verdict, a
|
|
6
|
+
// same-mistake finding, or a low-confidence planner verdict), decide
|
|
7
|
+
// whether the case is settled or must escalate to the operator with both
|
|
8
|
+
// rationales side by side.
|
|
9
|
+
//
|
|
10
|
+
// Shadow mode (Phase 8 design decision): until crosscheck.yml flips a
|
|
11
|
+
// provider's `mode` to `active`, its verdict is recorded for calibration
|
|
12
|
+
// analytics (providerAgreement() in db/queries.ts) but has ZERO gating
|
|
13
|
+
// power — computeQuorum() falls back to the native verdict alone whenever
|
|
14
|
+
// no ACTIVE external judge is present in the case, exactly the pre-Phase-8
|
|
15
|
+
// behavior. This is the mechanism that makes "flip crosscheck.yml" the
|
|
16
|
+
// entire promotion step (docs/runbooks/providers.md).
|
|
17
|
+
import { loadCrosscheckPolicy, providerModel } from './crosscheck.js';
|
|
18
|
+
import { SmithError } from './errors.js';
|
|
19
|
+
import { appendEvent } from './events.js';
|
|
20
|
+
import { runJudge } from './providers/index.js';
|
|
21
|
+
const DEFAULT_QUORUM_POLICY = { minProviders: 2 };
|
|
22
|
+
/**
|
|
23
|
+
* Bridge a raw JudgeResult into the binary confirm/refute quorum needs.
|
|
24
|
+
* `kind: review` has no explicit verdict field (its contract is a findings
|
|
25
|
+
* array, .claude/agents/reviewer.md) — presence of any finding IS the
|
|
26
|
+
* claim being judged ("this task has a real blocking issue"), so a
|
|
27
|
+
* non-empty array reads as `confirm`. `kind: verify`/`plan-critique` carry
|
|
28
|
+
* an explicit judge-verdict.schema.json object instead.
|
|
29
|
+
*/
|
|
30
|
+
export function deriveVerdict(result) {
|
|
31
|
+
if (result.kind === 'review') {
|
|
32
|
+
const findings = Array.isArray(result.output) ? result.output : [];
|
|
33
|
+
return {
|
|
34
|
+
provider: result.provider,
|
|
35
|
+
verdict: findings.length > 0 ? 'confirm' : 'refute',
|
|
36
|
+
rationale: findings.length > 0 ? `${findings.length} finding(s) raised.` : 'No findings raised.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const output = result.output;
|
|
40
|
+
const verdict = output.verdict === 'confirm' ? 'confirm' : 'refute';
|
|
41
|
+
return { provider: result.provider, verdict, rationale: output.rationale ?? '' };
|
|
42
|
+
}
|
|
43
|
+
function toRationales(participants) {
|
|
44
|
+
return participants
|
|
45
|
+
.filter((p) => p.ok && p.verdict !== null)
|
|
46
|
+
.map(({ provider, verdict, rationale }) => ({ provider, verdict, rationale }));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Pure decision function (table-driven testable): no I/O, no events. Applies
|
|
50
|
+
* quorum_rule (majority-of-N, generalizing "2-of-3") over the gating pool —
|
|
51
|
+
* native plus every ACTIVE, non-finder external participant. Shadow-mode
|
|
52
|
+
* and finder-excluded participants are recorded in `participants` for
|
|
53
|
+
* calibration but never enter the pool.
|
|
54
|
+
*/
|
|
55
|
+
export function computeQuorum(kase, policy = DEFAULT_QUORUM_POLICY) {
|
|
56
|
+
const nativeParticipant = {
|
|
57
|
+
provider: kase.native.provider,
|
|
58
|
+
mode: 'native',
|
|
59
|
+
ok: true,
|
|
60
|
+
verdict: kase.native.verdict,
|
|
61
|
+
rationale: kase.native.rationale,
|
|
62
|
+
excludedAsFinder: kase.native.provider === kase.finderProvider,
|
|
63
|
+
};
|
|
64
|
+
const externalParticipants = kase.external.map((run) => {
|
|
65
|
+
const excludedAsFinder = run.provider === kase.finderProvider;
|
|
66
|
+
if (!run.outcome.ok) {
|
|
67
|
+
return {
|
|
68
|
+
provider: run.provider,
|
|
69
|
+
mode: run.mode,
|
|
70
|
+
ok: false,
|
|
71
|
+
verdict: null,
|
|
72
|
+
rationale: run.outcome.error.message,
|
|
73
|
+
excludedAsFinder,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const verdict = deriveVerdict(run.outcome.result);
|
|
77
|
+
return {
|
|
78
|
+
provider: run.provider,
|
|
79
|
+
mode: run.mode,
|
|
80
|
+
ok: true,
|
|
81
|
+
verdict: verdict.verdict,
|
|
82
|
+
rationale: verdict.rationale,
|
|
83
|
+
excludedAsFinder,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const participants = [nativeParticipant, ...externalParticipants];
|
|
87
|
+
const caseSummary = {
|
|
88
|
+
taskId: kase.taskId,
|
|
89
|
+
findingId: kase.findingId,
|
|
90
|
+
triggerReason: kase.triggerReason,
|
|
91
|
+
};
|
|
92
|
+
const activeExternal = externalParticipants.filter((p) => p.mode === 'active' && p.ok && !p.excludedAsFinder);
|
|
93
|
+
// Shadow mode: no ACTIVE external participant in this case's pool yet —
|
|
94
|
+
// gating stays exactly what it always was pre-Phase-8, the native verdict
|
|
95
|
+
// alone (zero gating power for shadow-mode providers, per the header note).
|
|
96
|
+
if (activeExternal.length === 0) {
|
|
97
|
+
if (nativeParticipant.excludedAsFinder) {
|
|
98
|
+
return {
|
|
99
|
+
case: caseSummary,
|
|
100
|
+
gating: { outcome: 'escalate', reason: 'insufficient-providers', rationales: [] },
|
|
101
|
+
participants,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
case: caseSummary,
|
|
106
|
+
gating: {
|
|
107
|
+
outcome: 'decided',
|
|
108
|
+
decision: nativeParticipant.verdict,
|
|
109
|
+
agreement: 'native-only',
|
|
110
|
+
participants: [nativeParticipant.provider],
|
|
111
|
+
},
|
|
112
|
+
participants,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const gatingPool = [
|
|
116
|
+
...(nativeParticipant.excludedAsFinder ? [] : [nativeParticipant]),
|
|
117
|
+
...activeExternal,
|
|
118
|
+
];
|
|
119
|
+
if (gatingPool.length < policy.minProviders) {
|
|
120
|
+
return {
|
|
121
|
+
case: caseSummary,
|
|
122
|
+
gating: {
|
|
123
|
+
outcome: 'escalate',
|
|
124
|
+
reason: 'insufficient-providers',
|
|
125
|
+
rationales: toRationales(gatingPool),
|
|
126
|
+
},
|
|
127
|
+
participants,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const confirmCount = gatingPool.filter((p) => p.verdict === 'confirm').length;
|
|
131
|
+
const refuteCount = gatingPool.length - confirmCount;
|
|
132
|
+
const majority = Math.floor(gatingPool.length / 2) + 1;
|
|
133
|
+
if (confirmCount >= majority || refuteCount >= majority) {
|
|
134
|
+
return {
|
|
135
|
+
case: caseSummary,
|
|
136
|
+
gating: {
|
|
137
|
+
outcome: 'decided',
|
|
138
|
+
decision: confirmCount >= majority ? 'confirm' : 'refute',
|
|
139
|
+
agreement: `${Math.max(confirmCount, refuteCount)}-of-${gatingPool.length}`,
|
|
140
|
+
participants: gatingPool.map((p) => p.provider),
|
|
141
|
+
},
|
|
142
|
+
participants,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
case: caseSummary,
|
|
147
|
+
gating: { outcome: 'escalate', reason: 'disagreement', rationales: toRationales(gatingPool) },
|
|
148
|
+
participants,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Event recording — "Emit events for every judge run" (architecture §7/§8).
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
const KIND_TO_AGENT = {
|
|
155
|
+
review: 'reviewer',
|
|
156
|
+
verify: 'verifier',
|
|
157
|
+
'plan-critique': 'spec-reviewer',
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* One judge run -> one `dispatch_decision` event (so it shows up in the same
|
|
161
|
+
* analytics/timeline as any other agent dispatch, architecture §7) plus one
|
|
162
|
+
* `judge-verdict` event chained off it, always emitted whether the run
|
|
163
|
+
* succeeded or failed (the failure rates providerAgreement() reports need
|
|
164
|
+
* the failures on the log too — a call that throws before any event exists
|
|
165
|
+
* would make them unobservable).
|
|
166
|
+
*
|
|
167
|
+
* A failed run carries `error_code` as well as the message (D-253). The two
|
|
168
|
+
* are not interchangeable: the message is prose written for a human reading
|
|
169
|
+
* one row, the code is the field every count is grouped by, and
|
|
170
|
+
* judgeFailureKind() reads it to say whether the provider answered badly or
|
|
171
|
+
* never answered at all.
|
|
172
|
+
*/
|
|
173
|
+
export async function recordJudgeRun(input, ctx, opts = {}) {
|
|
174
|
+
const agentRole = KIND_TO_AGENT[input.kind];
|
|
175
|
+
const dispatch = await appendEvent({
|
|
176
|
+
session_id: ctx.sessionId,
|
|
177
|
+
actor: ctx.actor ?? 'system',
|
|
178
|
+
event_type: 'dispatch_decision',
|
|
179
|
+
task_id: input.taskId,
|
|
180
|
+
plan_version: ctx.planVersion,
|
|
181
|
+
causal_parent: ctx.causalParent,
|
|
182
|
+
payload: {
|
|
183
|
+
agent_role: agentRole,
|
|
184
|
+
provider: input.run.provider,
|
|
185
|
+
model_tier: input.modelTier,
|
|
186
|
+
model: input.model,
|
|
187
|
+
reason: `cross-provider judge (${input.run.mode})`,
|
|
188
|
+
},
|
|
189
|
+
}, opts);
|
|
190
|
+
const ok = input.run.outcome.ok;
|
|
191
|
+
const verdict = ok ? deriveVerdict(input.run.outcome.result) : null;
|
|
192
|
+
// A failed run produced no verdict, so there is nothing for the native one to
|
|
193
|
+
// agree or disagree with. `false` there would be a placeholder wearing an
|
|
194
|
+
// observation's clothes -- and a reader grouping by it counts a provider that
|
|
195
|
+
// never answered as one that dissented. `null` is how the record says the
|
|
196
|
+
// question has no answer, the same choice `latency_ms` below already makes
|
|
197
|
+
// (D-168). Rows written before this carry `false` permanently; providerAgreement()
|
|
198
|
+
// gates on `schema_failure` first for exactly that reason.
|
|
199
|
+
const agreementWithNative = ok ? verdict?.verdict === input.native.verdict : null;
|
|
200
|
+
const verdictEvent = await appendEvent({
|
|
201
|
+
session_id: ctx.sessionId,
|
|
202
|
+
actor: ctx.actor ?? 'system',
|
|
203
|
+
event_type: 'judge-verdict',
|
|
204
|
+
task_id: input.taskId,
|
|
205
|
+
plan_version: ctx.planVersion,
|
|
206
|
+
causal_parent: dispatch.event_id,
|
|
207
|
+
payload: {
|
|
208
|
+
task_id: input.taskId,
|
|
209
|
+
finding_id: input.findingId ?? null,
|
|
210
|
+
agent: agentRole,
|
|
211
|
+
provider: input.run.provider,
|
|
212
|
+
model_tier: input.modelTier,
|
|
213
|
+
model: input.model,
|
|
214
|
+
kind: input.kind,
|
|
215
|
+
mode: input.run.mode,
|
|
216
|
+
ok,
|
|
217
|
+
verdict: ok ? verdict?.verdict : null,
|
|
218
|
+
rationale: ok
|
|
219
|
+
? (verdict?.rationale ?? '')
|
|
220
|
+
: input.run.outcome.error.message,
|
|
221
|
+
native_verdict: input.native.verdict,
|
|
222
|
+
agreement_with_native: agreementWithNative,
|
|
223
|
+
schema_failure: !ok,
|
|
224
|
+
// `schema_failure` stays what it has always been -- "this run reached
|
|
225
|
+
// no verdict" -- because a decade of rows and ten-odd readers already
|
|
226
|
+
// spell it that way. What it cannot do is say *why*, and it was being
|
|
227
|
+
// read as though it could: runQuorumCase()'s catch had the code in
|
|
228
|
+
// hand and dropped it here, so eight straight deepseek runs killed by
|
|
229
|
+
// an unset DEEPSEEK_API_KEY were reported as unparseable answers from
|
|
230
|
+
// a provider that had not been sent a single request (D-253).
|
|
231
|
+
error_code: ok
|
|
232
|
+
? null
|
|
233
|
+
: input.run.outcome.error.code,
|
|
234
|
+
latency_ms: ok ? input.run.outcome.result.latency_ms : null,
|
|
235
|
+
},
|
|
236
|
+
}, opts);
|
|
237
|
+
return { dispatchEventId: dispatch.event_id, verdictEventId: verdictEvent.event_id };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Every non-native provider crosscheck.yml has switched on. `enabled: false`
|
|
241
|
+
* means never invoked at all regardless of `mode`, so an all-disabled policy
|
|
242
|
+
* (the shipped default) returns [] and callers can skip the case entirely —
|
|
243
|
+
* no judge call, no events, no spend.
|
|
244
|
+
*/
|
|
245
|
+
export function enabledExternalProviders(policy) {
|
|
246
|
+
return Object.values(policy.providers)
|
|
247
|
+
.filter((p) => p.kind !== 'native' && p.enabled)
|
|
248
|
+
.map((p) => p.name);
|
|
249
|
+
}
|
|
250
|
+
/** The provider running the factory itself (crosscheck.yml `kind: native`). */
|
|
251
|
+
export function nativeProviderName(policy) {
|
|
252
|
+
return Object.values(policy.providers).find((p) => p.kind === 'native')?.name ?? 'claude';
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Assemble the `verify` JudgeRequest for one finding, carrying
|
|
256
|
+
* crosscheck.yml's `asymmetric_roles.critic_mandate` ("refute, not confirm")
|
|
257
|
+
* into the prompt — the critic's job is to kill the finding, not to agree
|
|
258
|
+
* with it.
|
|
259
|
+
*
|
|
260
|
+
* Deliberate limitation: the prompt carries the CLAIM (summary + failure
|
|
261
|
+
* scenario + file path), never the file's contents or the diff. Shipping
|
|
262
|
+
* worktree source to a third-party API is an operator decision this call
|
|
263
|
+
* has no mandate to make, so a critic judges whether the claimed failure
|
|
264
|
+
* scenario is internally coherent and plausible, not whether the code
|
|
265
|
+
* really does that. Documented in docs/runbooks/providers.md §2.
|
|
266
|
+
*/
|
|
267
|
+
export function findingJudgeRequest(finding, filePath, budget) {
|
|
268
|
+
const prompt = [
|
|
269
|
+
'You are an adversarial critic in an automated code-review gate.',
|
|
270
|
+
'Another reviewer raised the finding below and it is about to block a task.',
|
|
271
|
+
'Your mandate is to REFUTE it: assume it is wrong until the stated failure',
|
|
272
|
+
'scenario forces you to agree. Confirm only if the described failure would',
|
|
273
|
+
'genuinely occur as written.',
|
|
274
|
+
'',
|
|
275
|
+
`Task: ${finding.task_id}`,
|
|
276
|
+
`File: ${filePath}`,
|
|
277
|
+
`Category: ${finding.finding_category}`,
|
|
278
|
+
`Severity: ${finding.severity}`,
|
|
279
|
+
`Summary: ${finding.summary}`,
|
|
280
|
+
'',
|
|
281
|
+
'Claimed failure scenario:',
|
|
282
|
+
` inputs: ${finding.failure_scenario.inputs}`,
|
|
283
|
+
` expected: ${finding.failure_scenario.expected}`,
|
|
284
|
+
` actual: ${finding.failure_scenario.actual}`,
|
|
285
|
+
'',
|
|
286
|
+
'You are judging the claim as stated; you do not have the file contents.',
|
|
287
|
+
'A claim that is vague, self-contradictory, or that would not actually',
|
|
288
|
+
'produce the stated `actual` is a refute.',
|
|
289
|
+
'',
|
|
290
|
+
'Return only JSON matching judge-verdict.schema.json:',
|
|
291
|
+
'{"verdict": "confirm" | "refute", "rationale": "<one or two sentences>"}',
|
|
292
|
+
'"confirm" = the finding is real and should block. "refute" = it should not.',
|
|
293
|
+
].join('\n');
|
|
294
|
+
return {
|
|
295
|
+
kind: 'verify',
|
|
296
|
+
taskId: finding.task_id,
|
|
297
|
+
inputRefs: { finding_id: finding.finding_id, fingerprint: finding.fingerprint, file: filePath },
|
|
298
|
+
prompt,
|
|
299
|
+
schemaName: 'judge-verdict',
|
|
300
|
+
budget,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
export async function runQuorumCase(input, ctx, opts = {}) {
|
|
304
|
+
const policy = input.policy ?? loadCrosscheckPolicy();
|
|
305
|
+
const external = [];
|
|
306
|
+
for (const providerName of input.providers) {
|
|
307
|
+
const config = policy.providers[providerName];
|
|
308
|
+
if (!config || config.kind === 'native' || !config.enabled)
|
|
309
|
+
continue;
|
|
310
|
+
let outcome;
|
|
311
|
+
try {
|
|
312
|
+
const result = await runJudge(providerName, input.request, {
|
|
313
|
+
policy,
|
|
314
|
+
fetchImpl: input.fetchImpl,
|
|
315
|
+
});
|
|
316
|
+
outcome = { ok: true, result };
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
outcome = {
|
|
320
|
+
ok: false,
|
|
321
|
+
error: {
|
|
322
|
+
code: err instanceof SmithError ? err.code : 'provider.unknown-error',
|
|
323
|
+
message: err instanceof Error ? err.message : String(err),
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const run = { provider: providerName, mode: config.mode, outcome };
|
|
328
|
+
external.push(run);
|
|
329
|
+
await recordJudgeRun({
|
|
330
|
+
taskId: input.taskId,
|
|
331
|
+
findingId: input.findingId,
|
|
332
|
+
modelTier: config.modelTier,
|
|
333
|
+
model: providerModel(config),
|
|
334
|
+
kind: input.kind,
|
|
335
|
+
run,
|
|
336
|
+
native: input.native,
|
|
337
|
+
}, ctx, opts);
|
|
338
|
+
}
|
|
339
|
+
return computeQuorum({
|
|
340
|
+
taskId: input.taskId,
|
|
341
|
+
findingId: input.findingId,
|
|
342
|
+
triggerReason: input.triggerReason,
|
|
343
|
+
finderProvider: input.finderProvider,
|
|
344
|
+
native: input.native,
|
|
345
|
+
external,
|
|
346
|
+
}, input.quorumPolicy);
|
|
347
|
+
}
|