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