@juzser/blacksmith 0.3.0 → 0.5.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 → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { JUDGE_REPORT_EVENT_TYPE } from './agents-registry.js';
|
|
4
|
+
import { isAuditAxisTaskId } from './audit.js';
|
|
5
|
+
import { sessionOwnerRole } from './delegation.js';
|
|
3
6
|
import { SmithError } from './errors.js';
|
|
4
7
|
import { appendEvent, eventTaskId, readLineageEvents, } from './events.js';
|
|
8
|
+
import { JUDGE_TURN_ROLES } from './judgeRoles.js';
|
|
9
|
+
import { compileSchemas, validateEachShape } from './schemas.js';
|
|
5
10
|
import { isQualifiedTaskId, taskIdsMatch } from './taskId.js';
|
|
11
|
+
import { loadTaxonomy } from './taxonomy.js';
|
|
6
12
|
export class JudgeError extends SmithError {
|
|
7
13
|
}
|
|
8
14
|
/**
|
|
@@ -37,6 +43,25 @@ export class JudgeError extends SmithError {
|
|
|
37
43
|
* refuses to score a task while it is non-empty.
|
|
38
44
|
*/
|
|
39
45
|
export const JUDGE_DISPATCH_EVENT_TYPE = 'dispatch_decision';
|
|
46
|
+
/**
|
|
47
|
+
* judgeRoles.ts's `JUDGE_TURN_ROLES` — the six `JUDGE_ROLES` (reviewer,
|
|
48
|
+
* verifier, grader, spec-reviewer, security-reviewer, auditor) plus `uiux` —
|
|
49
|
+
* imported rather than copied so this module and judgeRoles.ts can never
|
|
50
|
+
* drift apart on which roles a dispatch/report pair can open and close a
|
|
51
|
+
* turn for. A `dispatch_decision` from any other role is a coder or a
|
|
52
|
+
* merger, not a judge, even when its payload accidentally carries
|
|
53
|
+
* `declared_artifact` and `round` (a copy-pasted judge dispatch command, or a
|
|
54
|
+
* hand-written event) — folding it anyway would open a turn `judge-reported`
|
|
55
|
+
* can never close, and the gate would refuse the task forever.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately the wider set, not the plain `JUDGE_ROLES` six: uiux's visual
|
|
58
|
+
* pass is bracketed by `smith judge dispatch`/`smith judge report` in
|
|
59
|
+
* practice (wave.md:242-253) exactly like the six, so a real uiux dispatch
|
|
60
|
+
* must open a turn here too, even though `dispatchLint.ts`'s narrower
|
|
61
|
+
* artifact-line check and `judgeStopHook.ts`'s Stop guard stay scoped to the
|
|
62
|
+
* six (see judgeRoles.ts for why).
|
|
63
|
+
*/
|
|
64
|
+
const JUDGE_TURN_ROLE_SET = new Set(JUDGE_TURN_ROLES);
|
|
40
65
|
/**
|
|
41
66
|
* Re-exported, not re-declared. The registry owns the terminal-event
|
|
42
67
|
* vocabulary — this one next to `task-result-recorded` and `error-logged`,
|
|
@@ -61,6 +86,43 @@ const DEFAULT_PROVIDER = 'claude';
|
|
|
61
86
|
const DEFAULT_MODEL_TIER = 'frontier';
|
|
62
87
|
/** `--no-findings`: an operator attesting that a judge ran clean, recorded as an attestation and never as an artifact. */
|
|
63
88
|
const ATTESTED_BY = 'operator';
|
|
89
|
+
/**
|
|
90
|
+
* `uiux` is the only role that can open more than one turn per task — a
|
|
91
|
+
* pre-code spec turn and a post-test visual turn — because a spec judgment
|
|
92
|
+
* and a screenshot judgment answer different questions and must close
|
|
93
|
+
* independently (S2). Every other role opens exactly one turn per task, so
|
|
94
|
+
* `kind` has nothing to disambiguate for it.
|
|
95
|
+
*/
|
|
96
|
+
const UIUX_ROLE = 'uiux';
|
|
97
|
+
/**
|
|
98
|
+
* Exported so a caller that only has a kind to check, with no role to pair
|
|
99
|
+
* it with (e.g. `judge outstanding`'s `--kind` filter), can validate it
|
|
100
|
+
* against the same closed list `assertKindAllowed` uses, rather than
|
|
101
|
+
* defining the list a second time.
|
|
102
|
+
*/
|
|
103
|
+
export function isJudgeKind(value) {
|
|
104
|
+
return value === 'spec' || value === 'visual';
|
|
105
|
+
}
|
|
106
|
+
/** Read `judge_kind` back off a stored payload; anything else (absent, legacy) is "no kind". */
|
|
107
|
+
function kindField(payload) {
|
|
108
|
+
return isJudgeKind(payload.judge_kind) ? payload.judge_kind : null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `kind` is uiux-only. Any other role naming one is almost certainly a
|
|
112
|
+
* copy-pasted uiux command, not a real second turn — allowing it would let a
|
|
113
|
+
* reviewer open two turns under two kinds that nothing ever disambiguates
|
|
114
|
+
* back (every reader keys on role alone for the other five judges).
|
|
115
|
+
*/
|
|
116
|
+
function assertKindAllowed(role, kind) {
|
|
117
|
+
if (kind === undefined)
|
|
118
|
+
return;
|
|
119
|
+
if (!isJudgeKind(kind)) {
|
|
120
|
+
throw new JudgeError('judges.invalid-kind', `"kind" must be "spec" or "visual"; got ${JSON.stringify(kind)}.`, { kind });
|
|
121
|
+
}
|
|
122
|
+
if (role !== UIUX_ROLE) {
|
|
123
|
+
throw new JudgeError('judges.kind-not-allowed', `"--kind" only applies to the "${UIUX_ROLE}" role, the only one that opens more than one turn per task; "${role}" opens one turn and takes no kind.`, { agent_role: role, kind });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
64
126
|
/**
|
|
65
127
|
* The turn a record belongs to, tolerating the two spellings of one task id.
|
|
66
128
|
*
|
|
@@ -78,8 +140,8 @@ const ATTESTED_BY = 'operator';
|
|
|
78
140
|
* exactly the same ambiguity. Leaving both outstanding blocks the gate, and
|
|
79
141
|
* the operator's remedy is to qualify the id.
|
|
80
142
|
*/
|
|
81
|
-
function findTurn(turns, taskId, role) {
|
|
82
|
-
const matching = turns.filter((t) => t.role === role && taskIdsMatch(t.taskId, taskId));
|
|
143
|
+
function findTurn(turns, taskId, role, kind) {
|
|
144
|
+
const matching = turns.filter((t) => t.role === role && t.kind === kind && taskIdsMatch(t.taskId, taskId));
|
|
83
145
|
return matching.length === 1 ? matching[0] : undefined;
|
|
84
146
|
}
|
|
85
147
|
function stringField(payload, field) {
|
|
@@ -123,10 +185,20 @@ export function foldJudgeTurns(events, taskId) {
|
|
|
123
185
|
const round = numberField(record.payload, 'round');
|
|
124
186
|
// No declared artifact, no report owed: this is a coder or a merger, not
|
|
125
187
|
// a judge. `round` is required alongside it, so a half-formed payload is
|
|
126
|
-
// ignored rather than folded into a turn nobody can ever close.
|
|
188
|
+
// ignored rather than folded into a turn nobody can ever close. Nor does
|
|
189
|
+
// a role outside JUDGE_TURN_ROLE_SET open one — a non-judge dispatch that
|
|
190
|
+
// accidentally carries both fields is still not a judge (see the set's
|
|
191
|
+
// own comment).
|
|
127
192
|
if (role === undefined || declaredArtifact === undefined || round === undefined)
|
|
128
193
|
continue;
|
|
129
|
-
|
|
194
|
+
if (!JUDGE_TURN_ROLE_SET.has(role))
|
|
195
|
+
continue;
|
|
196
|
+
const kind = kindField(record.payload);
|
|
197
|
+
const dispatchedAt = record.ts;
|
|
198
|
+
const artifactMtimeAtDispatch = typeof record.payload.artifact_mtime_at_dispatch === 'number'
|
|
199
|
+
? record.payload.artifact_mtime_at_dispatch
|
|
200
|
+
: null;
|
|
201
|
+
const existing = findTurn(turns, recordTaskId, role, kind);
|
|
130
202
|
if (existing !== undefined) {
|
|
131
203
|
if (existing.round > round)
|
|
132
204
|
continue;
|
|
@@ -138,16 +210,21 @@ export function foldJudgeTurns(events, taskId) {
|
|
|
138
210
|
existing.reported = false;
|
|
139
211
|
existing.reportedArtifact = null;
|
|
140
212
|
existing.attested = false;
|
|
213
|
+
existing.dispatchedAt = dispatchedAt;
|
|
214
|
+
existing.artifactMtimeAtDispatch = artifactMtimeAtDispatch;
|
|
141
215
|
continue;
|
|
142
216
|
}
|
|
143
217
|
turns.push({
|
|
144
218
|
taskId: recordTaskId,
|
|
145
219
|
role,
|
|
220
|
+
kind,
|
|
146
221
|
round,
|
|
147
222
|
declaredArtifact,
|
|
148
223
|
reported: false,
|
|
149
224
|
reportedArtifact: null,
|
|
150
225
|
attested: false,
|
|
226
|
+
dispatchedAt,
|
|
227
|
+
artifactMtimeAtDispatch,
|
|
151
228
|
});
|
|
152
229
|
}
|
|
153
230
|
for (const { record } of events) {
|
|
@@ -161,7 +238,8 @@ export function foldJudgeTurns(events, taskId) {
|
|
|
161
238
|
const role = stringField(record.payload, 'agent_role');
|
|
162
239
|
if (role === undefined)
|
|
163
240
|
continue;
|
|
164
|
-
const
|
|
241
|
+
const kind = kindField(record.payload);
|
|
242
|
+
const turn = findTurn(turns, recordTaskId, role, kind);
|
|
165
243
|
if (turn === undefined || turn.round !== numberField(record.payload, 'round'))
|
|
166
244
|
continue;
|
|
167
245
|
turn.reported = true;
|
|
@@ -204,6 +282,27 @@ async function emit(eventType, payload, taskId, ctx, opts) {
|
|
|
204
282
|
payload,
|
|
205
283
|
}, opts);
|
|
206
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Who dispatched this session, read off the event graph -- the answer a
|
|
287
|
+
* `dispatch_decision` written with no explicit `--actor` wants before it
|
|
288
|
+
* falls back to `system` (issue #218).
|
|
289
|
+
*
|
|
290
|
+
* `smith judge dispatch` runs inside a wave-runner's own delegated session,
|
|
291
|
+
* and the CLI does not thread an `--actor` through that call. Defaulting to
|
|
292
|
+
* `system` was a placeholder, not a real writer, and `delegation.ts`'s rule 3
|
|
293
|
+
* reads every `dispatch_decision` in a delegated session as a second author
|
|
294
|
+
* unless its `actor` names the role that session belongs to. `sessionOwnerRole`
|
|
295
|
+
* is the same lookup rule 3 already does when it decides whose session this
|
|
296
|
+
* is, so a judge dispatch recorded through this default and a dispatch a
|
|
297
|
+
* wave-runner writes itself land under the same actor -- there is nothing
|
|
298
|
+
* left for rule 3 to flag. A session nothing dispatched (or a P9-7
|
|
299
|
+
* continuation, which is not delegation) still falls through to `system`
|
|
300
|
+
* unchanged, exactly as before.
|
|
301
|
+
*/
|
|
302
|
+
async function delegatedActor(sessionId, opts) {
|
|
303
|
+
const events = await readLineageEvents(sessionId, opts);
|
|
304
|
+
return sessionOwnerRole(events, sessionId) ?? undefined;
|
|
305
|
+
}
|
|
207
306
|
/**
|
|
208
307
|
* Record that a judge was dispatched and what file it owes. Emitted by the
|
|
209
308
|
* dispatcher BEFORE the agent runs — a dispatch recorded afterwards could only
|
|
@@ -211,18 +310,48 @@ async function emit(eventType, payload, taskId, ctx, opts) {
|
|
|
211
310
|
* problem.
|
|
212
311
|
*/
|
|
213
312
|
export async function recordJudgeDispatch(input, ctx, opts = {}) {
|
|
313
|
+
// Caught here, ahead of the taxonomy check `emit` runs below, so a role
|
|
314
|
+
// that IS a valid taxonomy agent but not a judge (a coder dispatch that
|
|
315
|
+
// copy-pasted a judge dispatch command, say) gets a message that names the
|
|
316
|
+
// real problem instead of passing taxonomy validation and opening a turn
|
|
317
|
+
// `judge report` can never close.
|
|
318
|
+
if (!JUDGE_TURN_ROLE_SET.has(input.role)) {
|
|
319
|
+
throw new JudgeError('judges.non-judge-role', `"${input.role}" is not a judge role; only ${JUDGE_TURN_ROLES.join(', ')} can open a judge turn.`, { task_id: input.taskId, agent_role: input.role });
|
|
320
|
+
}
|
|
321
|
+
assertKindAllowed(input.role, input.kind);
|
|
322
|
+
// A kindless uiux dispatch folds into a `kind: null` turn the uiux gate never
|
|
323
|
+
// reads, so a pass reported against it blocks forever. Reads of old logs
|
|
324
|
+
// still fold such turns; only a new write is refused.
|
|
325
|
+
if (input.role === UIUX_ROLE && input.kind === undefined) {
|
|
326
|
+
throw new JudgeError('judges.kind-required', `A "${UIUX_ROLE}" dispatch must name its turn: "--kind spec" for the pre-code spec, "--kind visual" for the post-test visual pass.`, { task_id: input.taskId, agent_role: input.role });
|
|
327
|
+
}
|
|
214
328
|
if (!Number.isInteger(input.round) || input.round < 1) {
|
|
215
329
|
throw new JudgeError('judges.invalid-round', `Judge rounds are 1-based integers; got ${JSON.stringify(input.round)}.`, { round: input.round, task_id: input.taskId, agent_role: input.role });
|
|
216
330
|
}
|
|
217
331
|
if (input.artifactPath.trim() === '') {
|
|
218
332
|
throw new JudgeError('judges.no-declared-artifact', 'A judge dispatch must declare the artifact it will write — that path is the finish line.', { task_id: input.taskId, agent_role: input.role });
|
|
219
333
|
}
|
|
334
|
+
// The dispatch guard and judge-stop both read an absolute path only; a
|
|
335
|
+
// relative one would be a finish line no hook can check.
|
|
336
|
+
if (!path.isAbsolute(input.artifactPath)) {
|
|
337
|
+
throw new JudgeError('judges.artifact-not-absolute', `The declared artifact "${input.artifactPath}" is not an absolute path; pass an absolute one.`, { task_id: input.taskId, agent_role: input.role });
|
|
338
|
+
}
|
|
339
|
+
// Both hooks parse the `Declared artifact:` line as one run of non-space
|
|
340
|
+
// characters, so a path with whitespace would print an expected_line the
|
|
341
|
+
// dispatch guard then refuses as missing.
|
|
342
|
+
if (/\s/.test(input.artifactPath)) {
|
|
343
|
+
throw new JudgeError('judges.artifact-has-whitespace', `The declared artifact "${input.artifactPath}" contains whitespace, which the "Declared artifact:" line cannot carry; pick a path without it.`, { task_id: input.taskId, agent_role: input.role });
|
|
344
|
+
}
|
|
220
345
|
// appendEvent would reject this too, on taxonomy grounds. Caught here anyway,
|
|
221
346
|
// so the message names the judge that was about to be dispatched instead of
|
|
222
347
|
// the record type that failed validation.
|
|
223
348
|
if (typeof input.model !== 'string' || input.model.trim() === '') {
|
|
224
349
|
throw new JudgeError('judges.no-model', `A judge dispatch must name the model it runs on: "${input.role}" is checked against the finder it followed by model id, not by tier (P9-23).`, { task_id: input.taskId, agent_role: input.role });
|
|
225
350
|
}
|
|
351
|
+
// Explicit `--actor` is authoritative and skips this lookup entirely; only
|
|
352
|
+
// an absent actor reaches for the delegated session's owner.
|
|
353
|
+
const actor = ctx.actor ?? (await delegatedActor(ctx.sessionId, opts));
|
|
354
|
+
const priorMtime = artifactMtime(input.artifactPath);
|
|
226
355
|
return emit(JUDGE_DISPATCH_EVENT_TYPE, {
|
|
227
356
|
agent_role: input.role,
|
|
228
357
|
provider: input.provider ?? DEFAULT_PROVIDER,
|
|
@@ -230,7 +359,71 @@ export async function recordJudgeDispatch(input, ctx, opts = {}) {
|
|
|
230
359
|
model: input.model,
|
|
231
360
|
round: input.round,
|
|
232
361
|
declared_artifact: input.artifactPath,
|
|
233
|
-
|
|
362
|
+
...(priorMtime === null ? {} : { artifact_mtime_at_dispatch: priorMtime }),
|
|
363
|
+
...(input.kind ? { judge_kind: input.kind } : {}),
|
|
364
|
+
}, input.taskId, { ...ctx, actor }, opts);
|
|
365
|
+
}
|
|
366
|
+
/** The file's mtime in epoch ms, or null when there is no file to date. */
|
|
367
|
+
function artifactMtime(artifactPath) {
|
|
368
|
+
try {
|
|
369
|
+
return statSync(artifactPath).mtimeMs;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* How far a fresh artifact's mtime may sit BEHIND its dispatch's `ts` and
|
|
377
|
+
* still count as written during the turn.
|
|
378
|
+
*
|
|
379
|
+
* Not zero, because the two stamps come from two clocks. `ts` is
|
|
380
|
+
* `Date.now()`; a file's mtime on Linux is taken from the kernel's coarse
|
|
381
|
+
* clock, which lags the real-time one by up to a scheduler tick (4 ms at the
|
|
382
|
+
* common HZ=250, 10 ms at HZ=100). A judge that answers inside one tick of
|
|
383
|
+
* its dispatch — a test, or a `[]` written back by a scripted run — would
|
|
384
|
+
* otherwise read as stale. Fifty milliseconds covers the tick with margin and
|
|
385
|
+
* reopens nothing the check exists for: the artifacts it refuses are an
|
|
386
|
+
* earlier round's, minutes to days old, and a file that existed at dispatch
|
|
387
|
+
* is refused by `artifactMtimeAtDispatch` regardless of this window.
|
|
388
|
+
*/
|
|
389
|
+
const ARTIFACT_CLOCK_TOLERANCE_MS = 50;
|
|
390
|
+
/**
|
|
391
|
+
* Refuse an artifact that was not written during this turn (issue #249).
|
|
392
|
+
*
|
|
393
|
+
* Two ways to be stale, one code, because the remedy is the same — the judge
|
|
394
|
+
* did not report this round; re-run it:
|
|
395
|
+
*
|
|
396
|
+
* - its mtime is not later than the dispatch that opened the round, so the
|
|
397
|
+
* file is an earlier round's, or a placeholder left behind;
|
|
398
|
+
* - it is the declared file, it was already there at dispatch, and its mtime
|
|
399
|
+
* has not moved since — nothing rewrote it, whatever the clock says.
|
|
400
|
+
* This is what catches a file dated in the future.
|
|
401
|
+
*
|
|
402
|
+
* Only the declared path is compared against the at-dispatch mtime: that is
|
|
403
|
+
* the only file whose mtime the dispatch recorded. An explicit `--artifact`
|
|
404
|
+
* (or `gate run --evidence`) elsewhere still has to postdate the dispatch.
|
|
405
|
+
*/
|
|
406
|
+
function assertArtifactFresh(artifactPath, turn) {
|
|
407
|
+
const mtime = artifactMtime(artifactPath);
|
|
408
|
+
// Missing is `readJudgeArtifact`'s refusal to make, with its own code.
|
|
409
|
+
if (mtime === null)
|
|
410
|
+
return;
|
|
411
|
+
const dispatchedMs = Date.parse(turn.dispatchedAt);
|
|
412
|
+
const context = {
|
|
413
|
+
artifact_path: artifactPath,
|
|
414
|
+
artifact_mtime: new Date(mtime).toISOString(),
|
|
415
|
+
dispatched_at: turn.dispatchedAt,
|
|
416
|
+
agent_role: turn.role,
|
|
417
|
+
round: turn.round,
|
|
418
|
+
};
|
|
419
|
+
if (mtime <= dispatchedMs - ARTIFACT_CLOCK_TOLERANCE_MS) {
|
|
420
|
+
throw new JudgeError('judges.artifact-stale', `Judge artifact "${artifactPath}" was last written at ${context.artifact_mtime}, before round ${turn.round} of "${turn.role}" was dispatched at ${turn.dispatchedAt}. It is an earlier round's file or a placeholder, not this turn's report; re-run the judge.`, context);
|
|
421
|
+
}
|
|
422
|
+
if (turn.artifactMtimeAtDispatch !== null &&
|
|
423
|
+
mtime === turn.artifactMtimeAtDispatch &&
|
|
424
|
+
path.resolve(artifactPath) === path.resolve(turn.declaredArtifact)) {
|
|
425
|
+
throw new JudgeError('judges.artifact-stale', `Judge artifact "${artifactPath}" already existed when round ${turn.round} of "${turn.role}" was dispatched, and nothing has rewritten it since. The judge did not report this round; re-run it.`, { ...context, artifact_mtime_at_dispatch: turn.artifactMtimeAtDispatch });
|
|
426
|
+
}
|
|
234
427
|
}
|
|
235
428
|
/** The one judge whose artifact is a verdict document rather than a findings list. */
|
|
236
429
|
const GRADER_ROLE = 'grader';
|
|
@@ -262,6 +455,143 @@ function graderFindingCount(parsed) {
|
|
|
262
455
|
return undefined;
|
|
263
456
|
return criteria.filter((c) => !(typeof c === 'object' && c !== null && c.status === 'pass')).length;
|
|
264
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* Count what a uiux result document holds, for the turn `kind` it closes.
|
|
460
|
+
*
|
|
461
|
+
* uiux's declared artifact is always the Result object `{run_status,
|
|
462
|
+
* structured_output, artifacts}` (bs-uiux.md), never a findings-evidence array —
|
|
463
|
+
* the bug this slice fixes is exactly that `readJudgeArtifact` used to throw
|
|
464
|
+
* on this real shape. A pre-code spec names no findings of its own (its
|
|
465
|
+
* `structured_output.deviations` are design-token departures the spec itself
|
|
466
|
+
* chose, not something to count against the task), so a `spec` turn is
|
|
467
|
+
* always 0. A post-test visual pass's `structured_output.deviations` IS the
|
|
468
|
+
* findings list — one entry per screenshot departure from the spec — so a
|
|
469
|
+
* `visual` turn counts them. `kind === null` is a legacy uiux turn dispatched
|
|
470
|
+
* before this slice; it has no way to say which shape it is, so it is read
|
|
471
|
+
* the lenient way — a real `deviations` array still counts, anything else is
|
|
472
|
+
* the pre-existing 0.
|
|
473
|
+
*/
|
|
474
|
+
function uiuxFindingCount(parsed, kind) {
|
|
475
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
476
|
+
return undefined;
|
|
477
|
+
// A spec turn's own `structured_output.deviations` are design-token
|
|
478
|
+
// departures the spec itself chose, never findings against the task, so a
|
|
479
|
+
// spec turn is always 0 regardless of what that array holds.
|
|
480
|
+
if (kind === 'spec')
|
|
481
|
+
return 0;
|
|
482
|
+
const document = parsed;
|
|
483
|
+
const structured = document.structured_output;
|
|
484
|
+
const deviations = typeof structured === 'object' && structured !== null
|
|
485
|
+
? structured.deviations
|
|
486
|
+
: undefined;
|
|
487
|
+
if (Array.isArray(deviations))
|
|
488
|
+
return deviations.length;
|
|
489
|
+
if (kind === 'visual')
|
|
490
|
+
return undefined;
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Map a uiux visual pass's deviations onto the findings-evidence shape `gate
|
|
495
|
+
* run --evidence` already feeds `mintFindings` (`FindingEvidence`,
|
|
496
|
+
* findings.ts:454; schema factory/specs/schema/finding-evidence.schema.json).
|
|
497
|
+
* `dimension: 'accessibility'` is the `a11y` taxonomy category; every other
|
|
498
|
+
* dimension (layout_spacing, consistency, states) is a `visual-design`
|
|
499
|
+
* departure from the spec, not an accessibility one.
|
|
500
|
+
*/
|
|
501
|
+
export function uiuxDeviationsToEvidence(deviations) {
|
|
502
|
+
return deviations.map((deviation) => ({
|
|
503
|
+
file_path: deviation.screenshot,
|
|
504
|
+
finding_category: deviation.dimension === 'accessibility' ? 'a11y' : 'visual-design',
|
|
505
|
+
severity: deviation.severity,
|
|
506
|
+
summary: `${deviation.dimension} deviation at ${deviation.viewport}/${deviation.theme}: ${deviation.expected}`,
|
|
507
|
+
failure_scenario: {
|
|
508
|
+
inputs: `viewport=${deviation.viewport} theme=${deviation.theme}`,
|
|
509
|
+
expected: deviation.expected,
|
|
510
|
+
actual: deviation.observed,
|
|
511
|
+
},
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
let cachedTaxonomyForSchemas;
|
|
515
|
+
let cachedSchemas;
|
|
516
|
+
/**
|
|
517
|
+
* Schemas only — no taxonomy is read on this path, unlike the `{taxonomy,
|
|
518
|
+
* schemas}` pair `events.ts`/`findings.ts` each resolve for themselves.
|
|
519
|
+
* `compileSchemas` still takes a taxonomy argument (it needs one to compile
|
|
520
|
+
* at all), so one is loaded and cached to satisfy that, but nothing here ever
|
|
521
|
+
* reads it back out.
|
|
522
|
+
*/
|
|
523
|
+
function resolveSchemas(opts) {
|
|
524
|
+
if (opts.schemas)
|
|
525
|
+
return opts.schemas;
|
|
526
|
+
if (cachedSchemas === undefined) {
|
|
527
|
+
if (cachedTaxonomyForSchemas === undefined)
|
|
528
|
+
cachedTaxonomyForSchemas = loadTaxonomy();
|
|
529
|
+
cachedSchemas = compileSchemas(cachedTaxonomyForSchemas);
|
|
530
|
+
}
|
|
531
|
+
return cachedSchemas;
|
|
532
|
+
}
|
|
533
|
+
function describeShapeFailures(failures) {
|
|
534
|
+
return failures
|
|
535
|
+
.map((f) => `index ${f.index} (${f.errors.map((e) => e.message).join('; ')})`)
|
|
536
|
+
.join(', ');
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Whether `role`'s array-shaped artifact, on this `taskId`, is finding-
|
|
540
|
+
* evidence and should be schema-checked. An allow-list, not a blanket check
|
|
541
|
+
* minus exceptions — each entry below was confirmed against the role's own
|
|
542
|
+
* output contract (`.claude/agents/*.md`) and, for `judge dispatch`/`judge
|
|
543
|
+
* report` specifically, `.claude/skills/bs/wave.md`'s list of the judges that
|
|
544
|
+
* bracket ("uiux visual pass, grader, reviewer, verifier, security-reviewer").
|
|
545
|
+
*
|
|
546
|
+
* - `reviewer` (bs-reviewer.md): its whole array is exactly the five
|
|
547
|
+
* finding-evidence keys.
|
|
548
|
+
* - `security-reviewer` (bs-security-reviewer.md): its per-task array is the
|
|
549
|
+
* same five keys as `reviewer`'s. Its OTHER artifact — the security axis of
|
|
550
|
+
* `/bs audit` — carries a sixth key, `confidence`, and drops
|
|
551
|
+
* `finding_category`, which `additionalProperties: false` rejects; that
|
|
552
|
+
* shape is validated on its own terms by `smith audit record`, not this
|
|
553
|
+
* schema. The two are told apart by `taskId`, not by role alone — an
|
|
554
|
+
* ordinary per-task security review has no other way to distinguish
|
|
555
|
+
* itself, and leaving the role exempt everywhere (as before) left real
|
|
556
|
+
* per-task security findings unvalidated.
|
|
557
|
+
* - `GRADER_ROLE`: its declared artifact is a verdict OBJECT, handled by
|
|
558
|
+
* `graderFindingCount` below and never reaching this check. But `gate run
|
|
559
|
+
* --evidence <file> --found-by grader` (FD-1) can also close a grader turn
|
|
560
|
+
* with an ordinary finding-evidence ARRAY, distinct from the `--grader
|
|
561
|
+
* <file>` verdict-document path, so the role belongs here for that case.
|
|
562
|
+
*
|
|
563
|
+
* Every other role keeps the old count-only behaviour:
|
|
564
|
+
* - `verifier` (bs-verifier.md) reports `{finding_id, verdict, rationale,
|
|
565
|
+
* failure_scenario}` — echoing a finding back rather than describing a
|
|
566
|
+
* fresh one — a different, and never finding-evidence, shape by design.
|
|
567
|
+
* - `uiux` (bs-uiux.md): its artifact is always the OBJECT `{run_status,
|
|
568
|
+
* structured_output, artifacts}`, never an array, so this check never
|
|
569
|
+
* applies to it regardless of allow-listing.
|
|
570
|
+
* - `auditor`: the other three audit axes, the same AuditEvidenceItem shape
|
|
571
|
+
* as security-reviewer's audit path — but auditor never reviews a single
|
|
572
|
+
* task (bs-auditor.md: "never per-task, never for a diff"), so there is no
|
|
573
|
+
* taskId to gate on; it stays exempt unconditionally.
|
|
574
|
+
* - `spec-reviewer` (bs-spec-reviewer.md): not on the list. Neither of its two
|
|
575
|
+
* array artifacts — the pre-code/close spec review (finding-evidence
|
|
576
|
+
* shaped) and the spec-vs-goal coverage check (`{clause, verdict,
|
|
577
|
+
* taskIds?, reason?}`, not finding-evidence shaped) — currently reaches
|
|
578
|
+
* this function at all: `smith epic spec-review`/`smith epic goal-check`
|
|
579
|
+
* read them directly (cli.ts), never through `judge report`, and
|
|
580
|
+
* wave.md's judge-dispatch/report roster does not name spec-reviewer.
|
|
581
|
+
* Allow-listing the role here would be inert today and, because its two
|
|
582
|
+
* artifacts disagree on shape with no taskId to tell them apart the way
|
|
583
|
+
* security-reviewer's two are told apart, a latent wrong-rejection the day
|
|
584
|
+
* something does route the goal-coverage array through this same role
|
|
585
|
+
* string.
|
|
586
|
+
* - anything this taxonomy does not yet name.
|
|
587
|
+
*/
|
|
588
|
+
function validatesFindingEvidence(role, taskId) {
|
|
589
|
+
if (role === 'reviewer' || role === GRADER_ROLE)
|
|
590
|
+
return true;
|
|
591
|
+
if (role === 'security-reviewer')
|
|
592
|
+
return !isAuditAxisTaskId(taskId);
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
265
595
|
/**
|
|
266
596
|
* Read a judge's artifact and answer how many findings it holds.
|
|
267
597
|
*
|
|
@@ -277,8 +607,19 @@ function graderFindingCount(parsed) {
|
|
|
277
607
|
* the grader result document is accepted beside the list, and the count is
|
|
278
608
|
* its non-pass criteria. No other role gets that reading — a reviewer that
|
|
279
609
|
* wrote a verdict wrote the wrong shape.
|
|
610
|
+
*
|
|
611
|
+
* A findings-evidence array is schema-validated element-wise before it is
|
|
612
|
+
* even counted (issue #233), but only for the roles `validatesFindingEvidence`
|
|
613
|
+
* names: a malformed array used to close the turn on a finding_count that
|
|
614
|
+
* `mintFindings` would later crash trying to mint from the same file, since
|
|
615
|
+
* `findings raise --evidence`/`gate run --evidence` read the identical
|
|
616
|
+
* artifact. Reusing `finding-evidence.schema.json` here keeps the two intake
|
|
617
|
+
* paths agreeing on what "malformed" means, for the roles that actually write
|
|
618
|
+
* that shape — a role whose real artifact is something else (verifier, an
|
|
619
|
+
* audit axis) is not "malformed" for failing to look like a finding. An empty
|
|
620
|
+
* array has nothing to validate, so `[]` still passes.
|
|
280
621
|
*/
|
|
281
|
-
export function readJudgeArtifact(artifactPath, role) {
|
|
622
|
+
export function readJudgeArtifact(artifactPath, role, taskId, kind = null, opts = {}) {
|
|
282
623
|
let raw;
|
|
283
624
|
try {
|
|
284
625
|
raw = readFileSync(artifactPath, 'utf8');
|
|
@@ -293,19 +634,53 @@ export function readJudgeArtifact(artifactPath, role) {
|
|
|
293
634
|
catch (err) {
|
|
294
635
|
throw new JudgeError('judges.artifact-unparseable', `Judge artifact "${artifactPath}" is not JSON. Prose in the artifact slot is the wave-4 failure exactly: fluent text that reads like a verdict and is not one.`, { artifact_path: artifactPath, cause: err instanceof Error ? err.message : String(err) });
|
|
295
636
|
}
|
|
296
|
-
if (Array.isArray(parsed))
|
|
637
|
+
if (Array.isArray(parsed)) {
|
|
638
|
+
// Only the roles `validatesFindingEvidence` names actually write a
|
|
639
|
+
// finding-evidence array; skipping the check for every other role does
|
|
640
|
+
// not weaken issue #233's guarantee — anything that reaches
|
|
641
|
+
// `mintFindings` is still validated in full before it is minted,
|
|
642
|
+
// regardless of role.
|
|
643
|
+
if (validatesFindingEvidence(role, taskId)) {
|
|
644
|
+
const failures = validateEachShape(resolveSchemas(opts), 'finding-evidence', parsed);
|
|
645
|
+
if (failures.length > 0) {
|
|
646
|
+
throw new JudgeError('judges.artifact-invalid-evidence', `Judge artifact "${artifactPath}" holds ${failures.length} item(s) that do not match finding-evidence.schema.json at ${describeShapeFailures(failures)}. Expected {file_path, finding_category, severity, summary, failure_scenario: {inputs, expected, actual}} (criterion_ref optional).`, { artifact_path: artifactPath, failures });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
297
649
|
return parsed.length;
|
|
650
|
+
}
|
|
298
651
|
const graderCount = role === GRADER_ROLE ? graderFindingCount(parsed) : undefined;
|
|
299
652
|
if (graderCount !== undefined)
|
|
300
653
|
return graderCount;
|
|
654
|
+
if (role === UIUX_ROLE) {
|
|
655
|
+
const uiuxCount = uiuxFindingCount(parsed, kind);
|
|
656
|
+
if (uiuxCount !== undefined)
|
|
657
|
+
return uiuxCount;
|
|
658
|
+
if (kind === 'visual') {
|
|
659
|
+
throw new JudgeError('judges.artifact-invalid-evidence', `Judge artifact "${artifactPath}" is a uiux visual-pass result with no structured_output.deviations array. A visual pass reports its deviations, "[]" when it found none.`, { artifact_path: artifactPath });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
301
662
|
const accepted = role === GRADER_ROLE
|
|
302
663
|
? 'a findings-evidence array or the grader result document ({run_status, structured_output: {criteria: [...]}})'
|
|
303
|
-
:
|
|
664
|
+
: role === UIUX_ROLE
|
|
665
|
+
? 'the uiux result document ({run_status, structured_output: {...}})'
|
|
666
|
+
: 'a findings-evidence array';
|
|
304
667
|
throw new JudgeError('judges.artifact-not-a-list', `Judge artifact "${artifactPath}" parsed to ${parsed === null ? 'null' : typeof parsed}, not ${accepted}. An empty review is "[]", written out.`, { artifact_path: artifactPath, agent_role: role ?? null });
|
|
305
668
|
}
|
|
306
|
-
function notDispatchedMessage(input, turn, forRole) {
|
|
307
|
-
if (
|
|
308
|
-
|
|
669
|
+
function notDispatchedMessage(input, turn, forRole, kindAmbiguous) {
|
|
670
|
+
if (kindAmbiguous.length > 1) {
|
|
671
|
+
const kinds = kindAmbiguous.map((t) => t.kind ?? 'null').join(', ');
|
|
672
|
+
if (kindAmbiguous.every((t) => t.reported)) {
|
|
673
|
+
return `Task id "${input.taskId}" has ${kindAmbiguous.length} "${input.role}" turns (kinds: ${kinds}), all already reported. Report with "--kind" naming the one to re-report — picking one here would be a guess.`;
|
|
674
|
+
}
|
|
675
|
+
return `Task id "${input.taskId}" has ${kindAmbiguous.length} open "${input.role}" turns (kinds: ${kinds}). Report with "--kind" naming the one to close — closing one here would be a guess.`;
|
|
676
|
+
}
|
|
677
|
+
const distinctTaskIds = new Set(forRole.map((t) => t.taskId));
|
|
678
|
+
if (distinctTaskIds.size > 1) {
|
|
679
|
+
return `Task id "${input.taskId}" names a "${input.role}" turn in ${distinctTaskIds.size} epics (${forRole.map((t) => t.taskId).join(', ')}). Report against the qualified id — closing one of them here would be a guess.`;
|
|
680
|
+
}
|
|
681
|
+
if (turn === undefined && input.kind !== undefined && forRole.length > 0) {
|
|
682
|
+
const kinds = forRole.map((t) => t.kind ?? 'null').join(', ');
|
|
683
|
+
return `No "${input.kind}" ${input.role} dispatch on ${input.taskId} (dispatched kinds: ${kinds}). Dispatch it with "--kind ${input.kind}" first, or report against a kind that was dispatched.`;
|
|
309
684
|
}
|
|
310
685
|
if (turn === undefined) {
|
|
311
686
|
return `No judge dispatch for role "${input.role}" on ${input.taskId}. Record the dispatch first — a report with no dispatch behind it proves nothing about coverage.`;
|
|
@@ -318,27 +693,49 @@ function notDispatchedMessage(input, turn, forRole) {
|
|
|
318
693
|
* the two sets agree by growing both.
|
|
319
694
|
*/
|
|
320
695
|
export async function recordJudgeReport(input, ctx, opts = {}) {
|
|
696
|
+
assertKindAllowed(input.role, input.kind);
|
|
697
|
+
const kind = input.kind ?? null;
|
|
321
698
|
const turns = await readJudgeTurns(input.taskId, ctx, opts);
|
|
322
|
-
const forRole = turns.filter((t) => t.role === input.role);
|
|
323
|
-
// More than one
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
699
|
+
const forRole = turns.filter((t) => t.role === input.role && taskIdsMatch(t.taskId, input.taskId));
|
|
700
|
+
// More than one distinct task id among same-role turns is a bare id that
|
|
701
|
+
// two epics both claim a turn for. More than one turn on the SAME task id
|
|
702
|
+
// (same role, same taskId) is the uiux spec/visual pair, disambiguated by
|
|
703
|
+
// `kind` instead — both cases are a guess to resolve on our own.
|
|
704
|
+
const distinctTaskIds = new Set(forRole.map((t) => t.taskId));
|
|
705
|
+
const epicAmbiguous = distinctTaskIds.size > 1;
|
|
706
|
+
// A kindless report picks among OPEN turns only, the same set
|
|
707
|
+
// `outstandingJudges` exposes — a closed turn is not a candidate to guess
|
|
708
|
+
// between, and the error must not count it as "open" either. With no turn
|
|
709
|
+
// open, every turn is a candidate again: one is a legacy re-report, two is
|
|
710
|
+
// the same guess, and must not silently re-report the first.
|
|
711
|
+
const openForRole = forRole.filter((t) => !t.reported);
|
|
712
|
+
const candidates = openForRole.length > 0 ? openForRole : forRole;
|
|
713
|
+
const kindAmbiguous = !epicAmbiguous && kind === null && candidates.length > 1;
|
|
714
|
+
const turn = epicAmbiguous || kindAmbiguous
|
|
715
|
+
? undefined
|
|
716
|
+
: kind === null
|
|
717
|
+
? candidates[0]
|
|
718
|
+
: forRole.find((t) => t.kind === kind);
|
|
327
719
|
if (turn === undefined || (input.round !== undefined && input.round !== turn.round)) {
|
|
328
|
-
throw new JudgeError('judges.not-dispatched', notDispatchedMessage(input, turn, forRole), {
|
|
720
|
+
throw new JudgeError('judges.not-dispatched', notDispatchedMessage(input, turn, forRole, kindAmbiguous ? candidates : []), {
|
|
329
721
|
task_id: input.taskId,
|
|
330
722
|
agent_role: input.role,
|
|
331
723
|
round: input.round ?? null,
|
|
332
724
|
});
|
|
333
725
|
}
|
|
334
726
|
const artifactPath = input.noFindings ? null : (input.artifactPath ?? turn.declaredArtifact);
|
|
335
|
-
|
|
336
|
-
|
|
727
|
+
if (artifactPath !== null)
|
|
728
|
+
assertArtifactFresh(artifactPath, turn);
|
|
729
|
+
const findingCount = artifactPath === null
|
|
730
|
+
? 0
|
|
731
|
+
: readJudgeArtifact(artifactPath, input.role, input.taskId, turn.kind, opts);
|
|
732
|
+
const stored = await emit(JUDGE_REPORT_EVENT_TYPE, {
|
|
337
733
|
agent_role: input.role,
|
|
338
734
|
round: turn.round,
|
|
339
735
|
artifact_path: artifactPath,
|
|
340
736
|
finding_count: findingCount,
|
|
341
737
|
...(input.noFindings ? { attested_by: ATTESTED_BY } : {}),
|
|
738
|
+
...(turn.kind ? { judge_kind: turn.kind } : {}),
|
|
342
739
|
}, input.taskId, ctx, opts);
|
|
343
740
|
return {
|
|
344
741
|
taskId: input.taskId,
|
|
@@ -347,5 +744,6 @@ export async function recordJudgeReport(input, ctx, opts = {}) {
|
|
|
347
744
|
artifactPath,
|
|
348
745
|
findingCount,
|
|
349
746
|
attested: input.noFindings === true,
|
|
747
|
+
event_id: stored.event_id,
|
|
350
748
|
};
|
|
351
749
|
}
|