@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,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
+ }