@juzser/blacksmith 0.4.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.
Files changed (131) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +4 -4
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +34 -27
  17. package/.claude/skills/bs/dispatch.md +100 -50
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +27 -18
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +181 -49
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +109 -53
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +48 -14
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +125 -17
  35. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  36. package/factory/orchestrator/dist/budgets.js +302 -84
  37. package/factory/orchestrator/dist/claims.js +9 -19
  38. package/factory/orchestrator/dist/cli.js +790 -100
  39. package/factory/orchestrator/dist/cliName.js +27 -0
  40. package/factory/orchestrator/dist/crosscheck.js +136 -8
  41. package/factory/orchestrator/dist/daemon.js +154 -36
  42. package/factory/orchestrator/dist/db/projector.js +278 -29
  43. package/factory/orchestrator/dist/db/queries.js +1831 -94
  44. package/factory/orchestrator/dist/db/schema.js +57 -4
  45. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  46. package/factory/orchestrator/dist/delegation.js +86 -19
  47. package/factory/orchestrator/dist/diffstat.js +15 -8
  48. package/factory/orchestrator/dist/dispatchLint.js +13 -37
  49. package/factory/orchestrator/dist/effort.js +14 -2
  50. package/factory/orchestrator/dist/effortTiers.js +13 -0
  51. package/factory/orchestrator/dist/env.js +26 -0
  52. package/factory/orchestrator/dist/epic.js +323 -38
  53. package/factory/orchestrator/dist/epicBudget.js +93 -0
  54. package/factory/orchestrator/dist/epicWidth.js +22 -5
  55. package/factory/orchestrator/dist/errorIssues.js +10 -4
  56. package/factory/orchestrator/dist/escalation.js +2 -2
  57. package/factory/orchestrator/dist/events.js +105 -3
  58. package/factory/orchestrator/dist/feedback.js +169 -0
  59. package/factory/orchestrator/dist/findingContext.js +26 -0
  60. package/factory/orchestrator/dist/findings.js +83 -12
  61. package/factory/orchestrator/dist/gate.js +120 -10
  62. package/factory/orchestrator/dist/goalCheck.js +2 -2
  63. package/factory/orchestrator/dist/harness.js +25 -17
  64. package/factory/orchestrator/dist/hookDecision.js +92 -3
  65. package/factory/orchestrator/dist/integration.js +9 -7
  66. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  67. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  68. package/factory/orchestrator/dist/judgeStopHook.js +90 -32
  69. package/factory/orchestrator/dist/judges.js +419 -21
  70. package/factory/orchestrator/dist/lessons.js +190 -11
  71. package/factory/orchestrator/dist/mcp.js +6 -6
  72. package/factory/orchestrator/dist/paths.js +10 -7
  73. package/factory/orchestrator/dist/plan.js +269 -33
  74. package/factory/orchestrator/dist/planQuorum.js +5 -2
  75. package/factory/orchestrator/dist/policy.js +1275 -30
  76. package/factory/orchestrator/dist/projectName.js +25 -0
  77. package/factory/orchestrator/dist/promptCapture.js +306 -0
  78. package/factory/orchestrator/dist/promptHook.js +33 -0
  79. package/factory/orchestrator/dist/promptImport.js +281 -0
  80. package/factory/orchestrator/dist/prompts.js +13 -0
  81. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  82. package/factory/orchestrator/dist/providers/types.js +1 -1
  83. package/factory/orchestrator/dist/queue.js +4 -4
  84. package/factory/orchestrator/dist/quorum.js +18 -5
  85. package/factory/orchestrator/dist/results.js +25 -7
  86. package/factory/orchestrator/dist/roadmap.js +10 -5
  87. package/factory/orchestrator/dist/run-cli.js +15 -10
  88. package/factory/orchestrator/dist/runner.js +2 -2
  89. package/factory/orchestrator/dist/schemas.js +56 -8
  90. package/factory/orchestrator/dist/severity.js +8 -4
  91. package/factory/orchestrator/dist/spec.js +177 -13
  92. package/factory/orchestrator/dist/specChange.js +13 -9
  93. package/factory/orchestrator/dist/stack.js +6 -6
  94. package/factory/orchestrator/dist/storeRoot.js +90 -0
  95. package/factory/orchestrator/dist/symbols.js +261 -6
  96. package/factory/orchestrator/dist/taskEvents.js +179 -27
  97. package/factory/orchestrator/dist/taskStatus.js +14 -0
  98. package/factory/orchestrator/dist/testerAudit.js +31 -12
  99. package/factory/orchestrator/dist/testgate.js +16 -13
  100. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  101. package/factory/orchestrator/dist/usage.js +97 -40
  102. package/factory/orchestrator/dist/waivers.js +39 -5
  103. package/factory/orchestrator/dist/waveBudget.js +2 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  105. package/factory/orchestrator/dist/workroot.js +1 -1
  106. package/factory/orchestrator/dist/worktree.js +61 -19
  107. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  108. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  109. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  110. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  111. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  112. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  113. package/factory/policies/budgets.yml +123 -33
  114. package/factory/policies/crosscheck.yml +51 -13
  115. package/factory/policies/delegation.yml +3 -3
  116. package/factory/policies/effort.yml +18 -10
  117. package/factory/policies/guardrails.yml +8 -6
  118. package/factory/policies/harness.yml +5 -5
  119. package/factory/policies/lessons.md +53 -2
  120. package/factory/policies/scheduler.yml +4 -4
  121. package/factory/policies/sensitive-paths.yml +1 -1
  122. package/factory/policies/severity.yml +13 -1
  123. package/factory/policies/stack.yml +6 -6
  124. package/factory/policies/taxonomy.yml +9 -3
  125. package/factory/policies/worktree.yml +15 -9
  126. package/factory/specs/roadmap.md +7 -7
  127. package/factory/specs/schema/event.schema.json +8 -1
  128. package/factory/specs/schema/result.schema.json +21 -8
  129. package/factory/specs/schema/task-spec.schema.json +12 -0
  130. package/package.json +16 -12
  131. 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
- const existing = findTurn(turns, recordTaskId, role);
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 turn = findTurn(turns, recordTaskId, role);
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
- }, input.taskId, ctx, opts);
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
- : 'a findings-evidence array';
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 (forRole.length > 1) {
308
- return `Task id "${input.taskId}" names a "${input.role}" turn in ${forRole.length} epics (${forRole.map((t) => t.taskId).join(', ')}). Report against the qualified id — closing one of them here would be a guess.`;
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 is a bare id that two epics both claim a turn for. Closing
324
- // either would be a guess, and `foldJudgeTurns` refuses the same ambiguity,
325
- // so the report emitted here would close nothing on the next read anyway.
326
- const turn = forRole.length === 1 ? forRole[0] : undefined;
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
- const findingCount = artifactPath === null ? 0 : readJudgeArtifact(artifactPath, input.role);
336
- await emit(JUDGE_REPORT_EVENT_TYPE, {
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
  }