@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
@@ -4,7 +4,7 @@ import { claimCoversPath } from './claims.js';
4
4
  import { SmithError } from './errors.js';
5
5
  import { appendEvent, readLineageEvents } from './events.js';
6
6
  import { describeType } from './plan.js';
7
- import { compileSchemas, validateRecord } from './schemas.js';
7
+ import { compileSchemas, validateEachShape, validateRecord, } from './schemas.js';
8
8
  import { readAddedTasks } from './taskEvents.js';
9
9
  import { bareTaskId, epicOfTaskId, taskIdsMatch } from './taskId.js';
10
10
  import { loadTaxonomy, validateTag } from './taxonomy.js';
@@ -326,6 +326,27 @@ const ORCHESTRATOR_OWNED_FINDING_FIELDS = [
326
326
  function mintFindingId(taskId, fingerprint) {
327
327
  return `f-${taskId}-${fingerprint.slice(0, 8)}`;
328
328
  }
329
+ /**
330
+ * A shallow clone with the orchestrator-owned fields removed, so the
331
+ * schema-shape check below runs against exactly the judge's half of the
332
+ * contract. `finding-evidence.schema.json` declares `additionalProperties:
333
+ * false` and never lists these six fields — they are the orchestrator's to
334
+ * mint, never the judge's to write (interview N-2) — so validating a raw
335
+ * evidence item against it, un-stripped, would reject every legitimate item
336
+ * that happens to be re-minted from a stored finding and would make the
337
+ * dedicated `findings.evidence-carries-identity` error below unreachable.
338
+ */
339
+ function stripOwnedFields(item) {
340
+ const clone = { ...item };
341
+ for (const field of ORCHESTRATOR_OWNED_FINDING_FIELDS)
342
+ delete clone[field];
343
+ return clone;
344
+ }
345
+ function describeShapeFailures(failures) {
346
+ return failures
347
+ .map((f) => `index ${f.index} (${f.errors.map((e) => e.message).join('; ')})`)
348
+ .join(', ');
349
+ }
329
350
  /**
330
351
  * Guess which taxonomy value a judge meant when it wrote something close but
331
352
  * not equal to one. Derived from the vocabulary itself rather than a synonym
@@ -367,7 +388,20 @@ function suggestCanonical(values, written) {
367
388
  * `test-gap` for `test-coverage` cost a whole gate round that way.
368
389
  */
369
390
  export function mintFindings(evidence, ctx, opts = {}) {
370
- const { taxonomy } = resolveTaxonomyAndSchemas(opts);
391
+ const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
392
+ // Issue #233: a wrong field name (`category`/`title`/`evidence` for
393
+ // `finding_category`/`summary`/none-of-those) or a wrong-shaped field
394
+ // (`failure_scenario` given as a string instead of {inputs, expected,
395
+ // actual}) used to reach `suggestCanonical`/`computeFingerprint` below and
396
+ // crash with a raw TypeError instead of a FindingError. Checked here,
397
+ // against the schema `finding-evidence.schema.json` already documents as
398
+ // the judge's half of the contract, every item's shape is settled before
399
+ // any item's fingerprint is computed or any finding is minted — so a batch
400
+ // with one bad item mints nothing, not "the good ones, then a crash."
401
+ const shapeFailures = validateEachShape(schemas, 'finding-evidence', evidence.map(stripOwnedFields));
402
+ if (shapeFailures.length > 0) {
403
+ throw new FindingError('findings.invalid-evidence', `Finding evidence does not match finding-evidence.schema.json at ${describeShapeFailures(shapeFailures)}. Expected {file_path, finding_category, severity, summary, failure_scenario: {inputs, expected, actual}} (criterion_ref optional).`, { failures: shapeFailures });
404
+ }
371
405
  const severities = taxonomy.dimensions.severity ?? [];
372
406
  const categories = taxonomy.dimensions.finding_category ?? [];
373
407
  return evidence.map((item, index) => {
@@ -531,7 +565,7 @@ export async function raiseFinding(input, ctx, opts = {}) {
531
565
  }, opts);
532
566
  return { suppressed: true, fingerprint, taskId: finding.task_id };
533
567
  }
534
- await appendEvent({
568
+ const stored = await appendEvent({
535
569
  session_id: ctx.sessionId,
536
570
  actor: ctx.actor ?? finding.found_by,
537
571
  event_type: 'finding-raised',
@@ -540,7 +574,7 @@ export async function raiseFinding(input, ctx, opts = {}) {
540
574
  causal_parent: ctx.causalParent,
541
575
  payload: finding,
542
576
  }, opts);
543
- return { suppressed: false, finding };
577
+ return { suppressed: false, finding, event_id: stored.event_id };
544
578
  }
545
579
  /**
546
580
  * Current state of every finding in this session's LINEAGE: a fold, never a
@@ -870,7 +904,7 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
870
904
  // answer to a caller who named none, rather than a status complaint.
871
905
  if (current.finding_status === 'waived') {
872
906
  if (extra.waiverRevokedBy === undefined) {
873
- throw new FindingError('findings.waiver-revocation-unproven', `Finding "${findingId}" is waived, and a waiver is undone by revoking it, not by typing a status at it. Record the operator's reversal first — "smith waiver apply" with a "denied" decision on this fingerprint reopens the finding as part of writing the denial.`, { findingId, to: newStatus });
907
+ throw new FindingError('findings.waiver-revocation-unproven', `Finding "${findingId}" is waived, and a waiver is undone by revoking it, not by typing a status at it. Record the operator's reversal first — "bs waiver apply" with a "denied" decision on this fingerprint reopens the finding as part of writing the denial.`, { findingId, to: newStatus });
874
908
  }
875
909
  if (newStatus !== preWaiverStatus) {
876
910
  throw new FindingError('findings.waiver-revocation-wrong-status', `Finding "${findingId}" was waived out of "${preWaiverStatus ?? 'an unrecorded status'}", so revoking that waiver returns it there, not to "${newStatus}". A revocation restores what the waiver closed over; it does not re-grade the finding.`, { findingId, to: newStatus, preWaiverStatus: preWaiverStatus ?? null });
@@ -883,14 +917,14 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
883
917
  throw new FindingError('findings.not-amendable', `Finding "${findingId}" is scoped "${findingScope(current)}", so it cannot take the amendment path at "${newStatus}": only a spec finding closes by amending the plan. A defect in the diff is closed by fixing the diff.`, { findingId, scope: findingScope(current), to: newStatus });
884
918
  }
885
919
  if (newStatus === AMEND_PENDING_STATUS && (extra.amendsTaskIds?.length ?? 0) === 0) {
886
- throw new FindingError('findings.amendment-without-obligation', `Finding "${findingId}" cannot enter "${AMEND_PENDING_STATUS}" naming no task ids. An amendment that obligates nothing discharges the finding the moment it is written, which is D-127 — say which added or superseded task this finding is now waiting on. "smith plan amend" derives that list from the plan diff and is the only verb that does (D-136).`, { findingId });
920
+ throw new FindingError('findings.amendment-without-obligation', `Finding "${findingId}" cannot enter "${AMEND_PENDING_STATUS}" naming no task ids. An amendment that obligates nothing discharges the finding the moment it is written, which is D-127 — say which added or superseded task this finding is now waiting on. "bs plan amend" derives that list from the plan diff and is the only verb that does (D-136).`, { findingId });
887
921
  }
888
922
  if (newStatus === AMENDED_STATUS) {
889
923
  const outstanding = outstandingObligations(current, extra.amendsSatisfiedBy ?? []);
890
924
  if (outstanding === null || outstanding.length > 0) {
891
925
  throw new FindingError('findings.amendment-not-discharged', outstanding === null
892
926
  ? `Finding "${findingId}" is at "${AMEND_PENDING_STATUS}" but names no task ids at a plan version, so no evidence can discharge it. Expire it at the epic boundary instead.`
893
- : `Finding "${findingId}" cannot close at "${AMENDED_STATUS}": ${outstanding.join(', ')} has not been shown to have landed at plan version ${current.amends_plan_version} or later. An amendment is discharged by its tasks landing, not by being told they did — this is D-127. "smith epic close" computes and supplies that evidence.`, {
927
+ : `Finding "${findingId}" cannot close at "${AMENDED_STATUS}": ${outstanding.join(', ')} has not been shown to have landed at plan version ${current.amends_plan_version} or later. An amendment is discharged by its tasks landing, not by being told they did — this is D-127. "bs epic close" computes and supplies that evidence.`, {
894
928
  findingId,
895
929
  outstanding: outstanding ?? [],
896
930
  amendsPlanVersion: current.amends_plan_version,
@@ -918,7 +952,7 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
918
952
  // landed tasks and their versions rather than having to re-derive them.
919
953
  if (extra.amendsSatisfiedBy !== undefined)
920
954
  payload.amends_satisfied_by = extra.amendsSatisfiedBy.map((row) => ({ ...row }));
921
- await appendEvent({
955
+ const stored = await appendEvent({
922
956
  session_id: ctx.sessionId,
923
957
  actor: ctx.actor ?? 'system',
924
958
  event_type: 'finding-transitioned',
@@ -935,6 +969,12 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
935
969
  ...(extra.amendsPlanVersion !== undefined
936
970
  ? { amends_plan_version: extra.amendsPlanVersion }
937
971
  : {}),
972
+ // #286: the id of the `finding-transitioned` event this call just
973
+ // appended, the same shape #238 gave `judge report` -- so a caller
974
+ // chaining transitions as `--causal-parent` has it without falling back
975
+ // to "log line count - 1", which names a sibling's event under parallel
976
+ // writers.
977
+ event_id: stored.event_id,
938
978
  };
939
979
  // A reopened finding carrying the id of the waiver that no longer holds it
940
980
  // is the same split state D-180 is about, one field smaller.
@@ -1068,7 +1108,7 @@ export async function repairObligation(input, ctx, opts = {}) {
1068
1108
  if (malformedEntries.length === 0) {
1069
1109
  throw new FindingError('findings.repair-not-corrupt', `Finding "${input.findingId}"'s obligation names ${original.length} task id(s), all ` +
1070
1110
  'well-formed — there is nothing to repair. This verb repairs malformed amends_task_ids ' +
1071
- 'data; it never renegotiates a well-formed obligation. Use "smith plan amend" to change ' +
1111
+ 'data; it never renegotiates a well-formed obligation. Use "bs plan amend" to change ' +
1072
1112
  'what a finding is waiting on.', { findingId: input.findingId });
1073
1113
  }
1074
1114
  // Guard 4: reason required, non-empty. An unaudited repair is not a repair.
@@ -1116,7 +1156,7 @@ export async function repairObligation(input, ctx, opts = {}) {
1116
1156
  if (current.amends_plan_version !== undefined) {
1117
1157
  payload.amends_plan_version = current.amends_plan_version;
1118
1158
  }
1119
- await appendEvent({
1159
+ const stored = await appendEvent({
1120
1160
  session_id: ctx.sessionId,
1121
1161
  actor: ctx.actor ?? 'user',
1122
1162
  event_type: FINDING_OBLIGATION_REPAIRED_EVENT_TYPE,
@@ -1125,7 +1165,12 @@ export async function repairObligation(input, ctx, opts = {}) {
1125
1165
  causal_parent: ctx.causalParent,
1126
1166
  payload,
1127
1167
  }, opts);
1128
- return { ...current, amends_task_ids: toObligation, obligation_repair_reason: reason };
1168
+ return {
1169
+ ...current,
1170
+ amends_task_ids: toObligation,
1171
+ obligation_repair_reason: reason,
1172
+ event_id: stored.event_id,
1173
+ };
1129
1174
  }
1130
1175
  /**
1131
1176
  * Which open findings are answering about code that no longer exists.
@@ -1204,9 +1249,34 @@ export async function staleFindings(sessionId, opts = {}) {
1204
1249
  const filePath = finding.file_path;
1205
1250
  if (filePath === undefined)
1206
1251
  continue;
1252
+ // #217: the finding's OWN task merging its file is not a rewrite — it is
1253
+ // exactly the diff the reviewer read when raising the finding, so
1254
+ // treating it as staleness meant a waiver batch refused to grant over
1255
+ // evidence a merge had not touched at all. But a re-cut does not always
1256
+ // get a new task_id: a same-id supersede (spec.ts's "a same-id supersede
1257
+ // needs no pairing") keeps the original id, so the same task_id CAN
1258
+ // appear in `merges` twice — once for the diff already read, once for a
1259
+ // genuine later rewrite. Only that one reviewed-diff merge is excluded;
1260
+ // a second one is real evidence of a rewrite and must still be able to
1261
+ // mark the finding stale.
1262
+ //
1263
+ // The reviewed-diff merge is anchored to `raisedIndex`, not `freshUntil`:
1264
+ // it is the first merge of the finding's own task after the RAISE, and a
1265
+ // later reverify must not shift which merge that is. Anchoring to
1266
+ // `freshUntil` instead let raise → v1 merge → reverify → v2 merge skip
1267
+ // TWO merges — `freshUntil` (moved past v1 by the reverify) filtered v1
1268
+ // out of the loop before the self-merge check ever saw it, and the
1269
+ // loop's "first self-merge encountered" rule then wrongly treated v2,
1270
+ // the genuine post-reverify rewrite, as if it were the reviewed diff.
1271
+ // Computed once by identity (its event index) rather than re-derived
1272
+ // inside the loop, so the freshUntil filter below cannot shift which
1273
+ // merge counts as "already read".
1274
+ const reviewedMergeIndex = merges.find((merge) => merge.index > raisedIndex && taskIdsMatch(merge.taskId, finding.task_id))?.index;
1207
1275
  for (const merge of merges) {
1208
1276
  if (merge.index <= freshUntil)
1209
1277
  continue;
1278
+ if (merge.index === reviewedMergeIndex)
1279
+ continue;
1210
1280
  if (merge.filesChanged) {
1211
1281
  const hit = merge.filesChanged.find((changed) => normalizeFilePath(changed) === filePath);
1212
1282
  if (hit === undefined)
@@ -1260,7 +1330,7 @@ export async function reverifyFinding(findingId, note, ctx, opts = {}) {
1260
1330
  findingId,
1261
1331
  });
1262
1332
  }
1263
- await appendEvent({
1333
+ const stored = await appendEvent({
1264
1334
  session_id: ctx.sessionId,
1265
1335
  actor: ctx.actor ?? 'system',
1266
1336
  event_type: 'finding-reverified',
@@ -1274,4 +1344,5 @@ export async function reverifyFinding(findingId, note, ctx, opts = {}) {
1274
1344
  note,
1275
1345
  },
1276
1346
  }, opts);
1347
+ return { event_id: stored.event_id };
1277
1348
  }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { TASK_RESULT_EVENT_TYPE } from './agents-registry.js';
5
5
  import { checkArtifacts } from './artifacts.js';
6
6
  import { recordReattribution, routeFindings } from './attribution.js';
7
+ import { readTokenUsage } from './budgetAlarm.js';
7
8
  import { checkTaskBudget } from './budgets.js';
8
9
  import { certifyCommit } from './commit.js';
9
10
  import { collectCoverageEvidence } from './coverage.js';
@@ -11,6 +12,7 @@ import { loadCrosscheckPolicy } from './crosscheck.js';
11
12
  import { DiffstatError, measureDiff } from './diffstat.js';
12
13
  import { appendEvent, readLineageEvents } from './events.js';
13
14
  import { findingScope, raiseFinding, SPEC_FINDING_SCOPE, transition, } from './findings.js';
15
+ import { runGit } from './git.js';
14
16
  import { outstandingJudges, readJudgeTurns } from './judges.js';
15
17
  import { enabledExternalProviders, findingJudgeRequest, nativeProviderName, runQuorumCase, } from './quorum.js';
16
18
  import { compileSchemas, validateRecord, } from './schemas.js';
@@ -19,6 +21,7 @@ import { readAddedTasks } from './taskEvents.js';
19
21
  import { taskIdsMatch } from './taskId.js';
20
22
  import { loadTaxonomy } from './taxonomy.js';
21
23
  import { run as runTestgate } from './testgate.js';
24
+ import { checkUiux } from './uiuxGate.js';
22
25
  let cachedTaxonomy;
23
26
  let cachedSchemas;
24
27
  function resolveTaxonomyAndSchemas(opts) {
@@ -31,7 +34,7 @@ function resolveTaxonomyAndSchemas(opts) {
31
34
  return { taxonomy, schemas };
32
35
  }
33
36
  async function emit(eventType, payload, taskId, ctx, opts) {
34
- await appendEvent({
37
+ return appendEvent({
35
38
  session_id: ctx.sessionId,
36
39
  actor: ctx.actor ?? 'system',
37
40
  event_type: eventType,
@@ -97,7 +100,7 @@ function resultHash(result) {
97
100
  async function recordResult(result, taskId, ctx, opts) {
98
101
  const hash = resultHash(result);
99
102
  const events = await readLineageEvents(ctx.sessionId, opts);
100
- for (const { record } of events) {
103
+ for (const { event_id, record } of events) {
101
104
  if (record.event_type !== TASK_RESULT_EVENT_TYPE)
102
105
  continue;
103
106
  // Matched, not compared: the same task reaches the log under both
@@ -111,9 +114,78 @@ async function recordResult(result, taskId, ctx, opts) {
111
114
  // Result verbatim (db/projector.ts reads it as one), and a hash field
112
115
  // wedged in beside it would be a Result that no longer validates.
113
116
  if (resultHash(record.payload) === hash)
114
- return;
117
+ return { eventId: event_id, deduped: true };
115
118
  }
116
- await emit(TASK_RESULT_EVENT_TYPE, result, taskId, ctx, opts);
119
+ const stored = await emit(TASK_RESULT_EVENT_TYPE, result, taskId, ctx, opts);
120
+ return { eventId: stored.event_id, deduped: false };
121
+ }
122
+ /** `name-desktop-light.png` and the three siblings a visual pass has to cover. */
123
+ const SCREENSHOT_RE = /-(desktop|mobile)-(light|dark)\.png$/;
124
+ function screenshotsFromArtifacts(artifacts) {
125
+ const shots = [];
126
+ for (const a of artifacts) {
127
+ const match = SCREENSHOT_RE.exec(a.path);
128
+ const viewport = match?.[1];
129
+ const theme = match?.[2];
130
+ if (viewport !== undefined && theme !== undefined)
131
+ shots.push({ path: a.path, viewport, theme });
132
+ }
133
+ return shots;
134
+ }
135
+ export async function recordTaskResult(input, ctx, opts = {}) {
136
+ const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
137
+ const schemaResult = validateRecord(schemas, taxonomy, 'result', input.result);
138
+ await emit('schema-check-result', { valid: schemaResult.valid, errors: schemaResult.valid ? [] : schemaResult.errors }, input.taskId, ctx, opts);
139
+ if (!schemaResult.valid) {
140
+ return {
141
+ outcome: 'blocked',
142
+ taskId: input.taskId,
143
+ reason: 'schema-invalid',
144
+ schemaErrors: schemaResult.errors,
145
+ artifactIssues: [],
146
+ };
147
+ }
148
+ // The worker's Result is a fact about a run that already happened, so it is
149
+ // recorded before the artifact check can block on it (P9-12) — a tester
150
+ // whose screenshot path was wrong still ran, and the projector should see
151
+ // the Result even when the artifact check below refuses it.
152
+ const recorded = await recordResult(input.result, input.taskId, ctx, opts);
153
+ const artifacts = input.result.artifacts ?? [];
154
+ const artifactCheck = checkArtifacts(artifacts, {
155
+ taskId: input.taskId,
156
+ artifactsDir: input.artifactsDir,
157
+ });
158
+ const freshness = input.worktreeDir !== undefined
159
+ ? {
160
+ head: runGit(input.worktreeDir, ['rev-parse', 'HEAD']),
161
+ screenshots: screenshotsFromArtifacts(artifacts),
162
+ }
163
+ : {};
164
+ await emit('artifact-check-result', {
165
+ ok: artifactCheck.ok,
166
+ checked: artifactCheck.checked,
167
+ home: artifactCheck.home,
168
+ issues: artifactCheck.issues,
169
+ ...freshness,
170
+ }, input.taskId, ctx, opts);
171
+ if (!artifactCheck.ok) {
172
+ return {
173
+ outcome: 'blocked',
174
+ taskId: input.taskId,
175
+ reason: 'artifacts-missing',
176
+ schemaErrors: [],
177
+ artifactIssues: artifactCheck.issues,
178
+ };
179
+ }
180
+ return {
181
+ outcome: 'recorded',
182
+ taskId: input.taskId,
183
+ eventId: recorded.eventId,
184
+ // Additive alongside the existing `eventId` (camelCase, pre-existing):
185
+ // same value, snake_case, matching every other write verb's printed id.
186
+ event_id: recorded.eventId,
187
+ deduped: recorded.deduped,
188
+ };
117
189
  }
118
190
  /** 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
191
  const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
@@ -231,7 +303,7 @@ function isRecord(value) {
231
303
  * load-bearing only if something loads it.
232
304
  *
233
305
  * The verdict lives at `.structured_output` of the result envelope the grader
234
- * writes (.claude/agents/grader.md, "Output contract"); the envelope itself is
306
+ * writes (.claude/agents/bs-grader.md, "Output contract"); the envelope itself is
235
307
  * a Result and is not re-checked here. Three ways to not pass: the file is not
236
308
  * shaped like a grader result (`grader-invalid`), the run came back `dead`
237
309
  * because the task spec had no checkable acceptance criteria (`grader-fail`,
@@ -707,6 +779,38 @@ export async function runGate(input, ctx, opts = {}) {
707
779
  outstandingJudges: outstanding,
708
780
  }, ctx, opts);
709
781
  }
782
+ // U2 S3: a ui-affecting task owes a reviewed, fresh screenshot set before
783
+ // the diff goes any further, measured against this run's own commit (D2).
784
+ // `uiAffecting` absent means no `--plan` was given at all, so the flag
785
+ // cannot be resolved either way — the stage records that honestly rather
786
+ // than guessing, and does not block (D6). `uiAffecting: false` (including a
787
+ // task with no flag at all, D3) means the stage has nothing to check.
788
+ let uiuxCheck = 'skipped';
789
+ if (input.uiAffecting === undefined) {
790
+ uiuxCheck = 'unverifiable';
791
+ }
792
+ else if (input.uiAffecting) {
793
+ uiuxCheck = 'checked';
794
+ const uiux = await checkUiux({
795
+ taskId: input.taskId,
796
+ ...(commitCheck.head === null ? {} : { head: commitCheck.head }),
797
+ artifactsDir: input.artifactsDir,
798
+ }, ctx, opts);
799
+ if (uiux.outcome === 'blocked') {
800
+ return finalize({
801
+ outcome: 'blocked',
802
+ taskId: input.taskId,
803
+ reason: uiux.reason,
804
+ testResult: null,
805
+ schemaErrors: [],
806
+ blockingFindings: [],
807
+ artifactIssues: [],
808
+ commitCheck,
809
+ uiuxCheck,
810
+ }, ctx, opts);
811
+ }
812
+ }
813
+ const uiuxStatus = { uiuxCheck };
710
814
  // Before the tests, not after: the grader has already run, and if its rubric
711
815
  // says the criteria were not met the diff bounces whatever the suite says —
712
816
  // so a full test run here is spent for nothing. It sits behind the commit
@@ -724,6 +828,7 @@ export async function runGate(input, ctx, opts = {}) {
724
828
  blockingFindings: [],
725
829
  artifactIssues: [],
726
830
  commitCheck,
831
+ ...uiuxStatus,
727
832
  }, ctx, opts);
728
833
  }
729
834
  }
@@ -753,6 +858,7 @@ export async function runGate(input, ctx, opts = {}) {
753
858
  artifactIssues: [],
754
859
  commitCheck,
755
860
  ...budget,
861
+ ...uiuxStatus,
756
862
  }, ctx, opts);
757
863
  }
758
864
  // After the tests pass, before the findings are judged: a green coverage
@@ -789,6 +895,7 @@ export async function runGate(input, ctx, opts = {}) {
789
895
  commitCheck,
790
896
  ...budget,
791
897
  ...withCoverage,
898
+ ...uiuxStatus,
792
899
  }, ctx, opts);
793
900
  }
794
901
  }
@@ -822,6 +929,7 @@ export async function runGate(input, ctx, opts = {}) {
822
929
  ...diverted,
823
930
  ...budget,
824
931
  ...withCoverage,
932
+ ...uiuxStatus,
825
933
  }, ctx, opts);
826
934
  }
827
935
  if (pendingWaivers.length > 0) {
@@ -836,6 +944,7 @@ export async function runGate(input, ctx, opts = {}) {
836
944
  ...diverted,
837
945
  ...budget,
838
946
  ...withCoverage,
947
+ ...uiuxStatus,
839
948
  }, ctx, opts);
840
949
  }
841
950
  return finalize({
@@ -848,12 +957,13 @@ export async function runGate(input, ctx, opts = {}) {
848
957
  ...diverted,
849
958
  ...budget,
850
959
  ...withCoverage,
960
+ ...uiuxStatus,
851
961
  }, ctx, opts);
852
962
  }
853
- /** Real token spend off the already-schema-valid result, or undefined if absent. */
963
+ /** Real token spend off the already-schema-valid result; undefined when absent or an unmeasured placeholder. */
854
964
  function tokensSpent(result) {
855
- const usage = result?.token_usage;
856
- return typeof usage?.total_tokens === 'number' ? usage.total_tokens : undefined;
965
+ const read = readTokenUsage(result?.token_usage);
966
+ return read.measured ? read.total : undefined;
857
967
  }
858
968
  /**
859
969
  * Declared budget vs. measured spend, for one task. The check never blocks: an
@@ -906,6 +1016,6 @@ function runBudgetCheck(input) {
906
1016
  }
907
1017
  }
908
1018
  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;
1019
+ const stored = await emit('gate-outcome', { outcome: outcome.outcome, reason: 'reason' in outcome ? outcome.reason : null }, outcome.taskId, ctx, opts);
1020
+ return { ...outcome, event_id: stored.event_id };
911
1021
  }
@@ -197,7 +197,7 @@ export function goalCheckBlockers(epicId, status, planVersion, livePlanTaskIds)
197
197
  }
198
198
  if (check === null) {
199
199
  return [
200
- `Epic "${epicId}" has no spec-vs-goal check on record: nothing has asked whether its plan answers the goal milestone "${goal.milestoneId}" declares. Run \`smith epic goal-check --epic ${epicId}\`.`,
200
+ `Epic "${epicId}" has no spec-vs-goal check on record: nothing has asked whether its plan answers the goal milestone "${goal.milestoneId}" declares. Run \`bs epic goal-check --epic ${epicId}\`.`,
201
201
  ];
202
202
  }
203
203
  if (check.goalDigest !== goal.digest) {
@@ -246,7 +246,7 @@ export function goalCheckBlockers(epicId, status, planVersion, livePlanTaskIds)
246
246
  const uncovered = check.coverage.filter((entry) => entry.verdict === 'uncovered');
247
247
  if (uncovered.length > 0) {
248
248
  return [
249
- `The spec-vs-goal check for "${epicId}" left ${uncovered.length} goal clause(s) uncovered by plan v${check.planVersion}: ${uncovered.map((entry) => JSON.stringify(entry.clause)).join('; ')}. Answer them with \`smith plan amend\` — no task diff can contain the fix.`,
249
+ `The spec-vs-goal check for "${epicId}" left ${uncovered.length} goal clause(s) uncovered by plan v${check.planVersion}: ${uncovered.map((entry) => JSON.stringify(entry.clause)).join('; ')}. Answer them with \`bs plan amend\` — no task diff can contain the fix.`,
250
250
  ];
251
251
  }
252
252
  return [];
@@ -1,10 +1,13 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parse as parseYaml } from 'yaml';
4
- import { loadBudgetPolicy } from './budgets.js';
4
+ import { agentNameFor, templateFileFor } from './agentNames.js';
5
+ import { loadBudgetPolicy, roleCapTokens } from './budgets.js';
6
+ import { epicBudgetPolicies } from './epicBudget.js';
5
7
  import { SmithError } from './errors.js';
6
8
  import { AGENTS_DIR, HARNESS_POLICY_PATH, REPO_ROOT } from './paths.js';
7
9
  import { INSPECTED_FILE_TOOLS, loadGuardrailPolicy } from './policy.js';
10
+ import { epicOfTaskId } from './taskId.js';
8
11
  import { loadTaxonomy } from './taxonomy.js';
9
12
  /**
10
13
  * The worker harness port: *how* a turn runs, named at last.
@@ -298,7 +301,7 @@ export function roleAccess(role, guardrails = loadGuardrailPolicy()) {
298
301
  }
299
302
  /** Where a role's prompt template lives, and whether it is there. */
300
303
  function templateFor(role, agentsDir) {
301
- const file = path.join(agentsDir, `${role}.md`);
304
+ const file = path.join(agentsDir, templateFileFor(role));
302
305
  return { file, exists: existsSync(file) };
303
306
  }
304
307
  function serves(harness, role) {
@@ -347,17 +350,22 @@ function resolveSchema(name, harnessName) {
347
350
  }
348
351
  return { name, file };
349
352
  }
350
- function capTokensFor(role, access, budgets) {
351
- // Only these three named roles carry a cap in budgets.yml today — every
352
- // other role (security-reviewer, merger, tester, uiux, planner, scribe)
353
- // has none, and null says so rather than inventing a number nobody set.
354
- if (access === 'judge')
355
- return budgets.task.judges.capTokens;
356
- if (role === 'coder')
357
- return budgets.task.coder.capTokens;
358
- if (role === 'researcher')
359
- return budgets.task.researcher.capTokens;
360
- return null;
353
+ function capTokensFor(role, budgets) {
354
+ // budgets.yml prices each role on its own line, sized for the policy's
355
+ // effort tier. A role it prices nowhere (merger, uiux, scribe, wave-runner)
356
+ // gets null, which says so rather than inventing a number nobody set.
357
+ return roleCapTokens(budgets, role);
358
+ }
359
+ /**
360
+ * budgets.yml sized for the effort tier of the epic `taskId` belongs to — the
361
+ * tier `cap_tokens` (and so the runner's `over_budget`) is judged at. A bare
362
+ * task id names no epic, and is priced at effort.yml's default tier.
363
+ */
364
+ function budgetsForTask(taskId, planOpts) {
365
+ const epicId = epicOfTaskId(taskId);
366
+ return epicId === null
367
+ ? loadBudgetPolicy()
368
+ : epicBudgetPolicies(planOpts === undefined ? {} : { planOpts })(epicId);
361
369
  }
362
370
  function substitute(template, values, harness) {
363
371
  return template.replace(PLACEHOLDER_RE, (_match, token) => {
@@ -471,7 +479,7 @@ export function planWorkerTurn(request, options = {}) {
471
479
  throw new HarnessError('harness.unknown-tier', `"${request.tier}" is not a model tier. It is one of ${MODEL_TIERS.join(', ')}.`, { tier: request.tier });
472
480
  }
473
481
  if (request.promptFile === undefined || request.promptFile === '') {
474
- throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`smith prompt record`), then plan the turn that carries it.', { role });
482
+ throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`bs prompt record`), then plan the turn that carries it.', { role });
475
483
  }
476
484
  const name = request.harness ?? policy.defaultHarness;
477
485
  const harness = policy.harnesses.find((h) => h.name === name);
@@ -509,7 +517,7 @@ export function planWorkerTurn(request, options = {}) {
509
517
  return {
510
518
  ...base,
511
519
  kind: 'in-process',
512
- subagentType: role,
520
+ subagentType: agentNameFor(role),
513
521
  template: templateRelative,
514
522
  };
515
523
  }
@@ -536,11 +544,11 @@ export function planWorkerTurn(request, options = {}) {
536
544
  const roleArgs = access === 'judge' ? harness.judgeArgs : harness.workerArgs;
537
545
  const rawArgs = [...harness.args, ...roleArgs, ...(schema !== null ? harness.schemaArgs : [])];
538
546
  const args = rawArgs.map((arg) => substitute(arg, values, harness.name));
539
- const budgets = options.budgets ?? loadBudgetPolicy();
547
+ const budgets = options.budgets ?? budgetsForTask(taskId, options.planOpts);
540
548
  const budget = {
541
549
  timeout_ms: harness.timeoutMs,
542
550
  max_output_bytes: harness.maxOutputBytes ?? HARNESS_DEFAULT_MAX_OUTPUT_BYTES,
543
- cap_tokens: capTokensFor(role, access, budgets),
551
+ cap_tokens: capTokensFor(role, budgets),
544
552
  };
545
553
  return {
546
554
  ...base,