@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.
Files changed (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  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} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -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} +7 -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 +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  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 +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  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 +143 -50
  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 +70 -13
  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 +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. 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';
@@ -250,7 +250,9 @@ function escapeRegExp(text) {
250
250
  * forms) — never a bare `:digits` anywhere in the text. A blanket
251
251
  * `:\d+` strip is wrong: free-text like "debug endpoint exposed on :8080"
252
252
  * would collide with an unrelated ":9090" finding (reproduced regression,
253
- * reviewer finding #3).
253
+ * reviewer finding #3). The line number may itself be a range
254
+ * (`Widget.tsx:1549-1576`), which a re-review reports as often as a single
255
+ * line (issue #178).
254
256
  */
255
257
  function stripPathLineRef(summary, filePath) {
256
258
  const forwardSlashPath = filePath.replace(/\\/g, '/');
@@ -259,16 +261,37 @@ function stripPathLineRef(summary, filePath) {
259
261
  for (const candidate of candidates) {
260
262
  if (!candidate)
261
263
  continue;
262
- const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?::\\d+)?`, 'g');
264
+ const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?:[-–—]\\d+)?(?::\\d+)?`, 'g');
263
265
  result = result.replace(pattern, candidate);
264
266
  }
265
267
  return result;
266
268
  }
269
+ /**
270
+ * A `lines N-M` / `lines N–M` (en dash) / `line N to line M` range — the
271
+ * shape a judge describing a large component actually writes, and the one
272
+ * the original single `line N` strip below never covered (issue #178).
273
+ * Anchored on the word "line"/"lines" immediately before the numbers, so
274
+ * free-text numerics elsewhere in the sentence are never touched.
275
+ */
276
+ const LINE_SPAN_RE = /\blines?\s+\d[\d,]*\s*(?:[-–—]|to(?:\s+lines?)?)\s*\d[\d,]*\b/g;
277
+ /**
278
+ * `~5,050-line` / `6,720-line` — a size given as a hyphenated adjective
279
+ * before "line"/"component"/"file"/etc, approximate or exact. Only the
280
+ * `<number>-line` token is removed; the noun it modifies is untouched, and
281
+ * a hyphenated count before any OTHER noun (`3-day`, `5-retry`) is left
282
+ * alone — it is not a line reference.
283
+ */
284
+ const LINE_COUNT_RE = /~?\d[\d,]*-line\b/g;
285
+ /** `L1549-L6600` — a GitHub-style permalink line range, bare or after `#`. */
286
+ const GITHUB_LINE_RANGE_RE = /\bl\d+-l\d+\b/g;
267
287
  /** lowercase, whitespace collapsed (spec wording; path-anchored line refs stripped by the caller). */
268
288
  function normalizeSummary(summary) {
269
289
  return summary
270
290
  .toLowerCase()
291
+ .replace(LINE_SPAN_RE, '')
271
292
  .replace(/\bline\s+\d+\b/g, '')
293
+ .replace(LINE_COUNT_RE, '')
294
+ .replace(GITHUB_LINE_RANGE_RE, '')
272
295
  .replace(/\s+/g, ' ')
273
296
  .trim();
274
297
  }
@@ -303,6 +326,27 @@ const ORCHESTRATOR_OWNED_FINDING_FIELDS = [
303
326
  function mintFindingId(taskId, fingerprint) {
304
327
  return `f-${taskId}-${fingerprint.slice(0, 8)}`;
305
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
+ }
306
350
  /**
307
351
  * Guess which taxonomy value a judge meant when it wrote something close but
308
352
  * not equal to one. Derived from the vocabulary itself rather than a synonym
@@ -344,7 +388,20 @@ function suggestCanonical(values, written) {
344
388
  * `test-gap` for `test-coverage` cost a whole gate round that way.
345
389
  */
346
390
  export function mintFindings(evidence, ctx, opts = {}) {
347
- 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
+ }
348
405
  const severities = taxonomy.dimensions.severity ?? [];
349
406
  const categories = taxonomy.dimensions.finding_category ?? [];
350
407
  return evidence.map((item, index) => {
@@ -508,7 +565,7 @@ export async function raiseFinding(input, ctx, opts = {}) {
508
565
  }, opts);
509
566
  return { suppressed: true, fingerprint, taskId: finding.task_id };
510
567
  }
511
- await appendEvent({
568
+ const stored = await appendEvent({
512
569
  session_id: ctx.sessionId,
513
570
  actor: ctx.actor ?? finding.found_by,
514
571
  event_type: 'finding-raised',
@@ -517,7 +574,7 @@ export async function raiseFinding(input, ctx, opts = {}) {
517
574
  causal_parent: ctx.causalParent,
518
575
  payload: finding,
519
576
  }, opts);
520
- return { suppressed: false, finding };
577
+ return { suppressed: false, finding, event_id: stored.event_id };
521
578
  }
522
579
  /**
523
580
  * Current state of every finding in this session's LINEAGE: a fold, never a
@@ -847,7 +904,7 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
847
904
  // answer to a caller who named none, rather than a status complaint.
848
905
  if (current.finding_status === 'waived') {
849
906
  if (extra.waiverRevokedBy === undefined) {
850
- 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 });
851
908
  }
852
909
  if (newStatus !== preWaiverStatus) {
853
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 });
@@ -860,14 +917,14 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
860
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 });
861
918
  }
862
919
  if (newStatus === AMEND_PENDING_STATUS && (extra.amendsTaskIds?.length ?? 0) === 0) {
863
- 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 });
864
921
  }
865
922
  if (newStatus === AMENDED_STATUS) {
866
923
  const outstanding = outstandingObligations(current, extra.amendsSatisfiedBy ?? []);
867
924
  if (outstanding === null || outstanding.length > 0) {
868
925
  throw new FindingError('findings.amendment-not-discharged', outstanding === null
869
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.`
870
- : `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.`, {
871
928
  findingId,
872
929
  outstanding: outstanding ?? [],
873
930
  amendsPlanVersion: current.amends_plan_version,
@@ -895,7 +952,7 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
895
952
  // landed tasks and their versions rather than having to re-derive them.
896
953
  if (extra.amendsSatisfiedBy !== undefined)
897
954
  payload.amends_satisfied_by = extra.amendsSatisfiedBy.map((row) => ({ ...row }));
898
- await appendEvent({
955
+ const stored = await appendEvent({
899
956
  session_id: ctx.sessionId,
900
957
  actor: ctx.actor ?? 'system',
901
958
  event_type: 'finding-transitioned',
@@ -912,6 +969,12 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
912
969
  ...(extra.amendsPlanVersion !== undefined
913
970
  ? { amends_plan_version: extra.amendsPlanVersion }
914
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,
915
978
  };
916
979
  // A reopened finding carrying the id of the waiver that no longer holds it
917
980
  // is the same split state D-180 is about, one field smaller.
@@ -1045,7 +1108,7 @@ export async function repairObligation(input, ctx, opts = {}) {
1045
1108
  if (malformedEntries.length === 0) {
1046
1109
  throw new FindingError('findings.repair-not-corrupt', `Finding "${input.findingId}"'s obligation names ${original.length} task id(s), all ` +
1047
1110
  'well-formed — there is nothing to repair. This verb repairs malformed amends_task_ids ' +
1048
- '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 ' +
1049
1112
  'what a finding is waiting on.', { findingId: input.findingId });
1050
1113
  }
1051
1114
  // Guard 4: reason required, non-empty. An unaudited repair is not a repair.
@@ -1093,7 +1156,7 @@ export async function repairObligation(input, ctx, opts = {}) {
1093
1156
  if (current.amends_plan_version !== undefined) {
1094
1157
  payload.amends_plan_version = current.amends_plan_version;
1095
1158
  }
1096
- await appendEvent({
1159
+ const stored = await appendEvent({
1097
1160
  session_id: ctx.sessionId,
1098
1161
  actor: ctx.actor ?? 'user',
1099
1162
  event_type: FINDING_OBLIGATION_REPAIRED_EVENT_TYPE,
@@ -1102,7 +1165,12 @@ export async function repairObligation(input, ctx, opts = {}) {
1102
1165
  causal_parent: ctx.causalParent,
1103
1166
  payload,
1104
1167
  }, opts);
1105
- 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
+ };
1106
1174
  }
1107
1175
  /**
1108
1176
  * Which open findings are answering about code that no longer exists.
@@ -1181,9 +1249,34 @@ export async function staleFindings(sessionId, opts = {}) {
1181
1249
  const filePath = finding.file_path;
1182
1250
  if (filePath === undefined)
1183
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;
1184
1275
  for (const merge of merges) {
1185
1276
  if (merge.index <= freshUntil)
1186
1277
  continue;
1278
+ if (merge.index === reviewedMergeIndex)
1279
+ continue;
1187
1280
  if (merge.filesChanged) {
1188
1281
  const hit = merge.filesChanged.find((changed) => normalizeFilePath(changed) === filePath);
1189
1282
  if (hit === undefined)
@@ -1237,7 +1330,7 @@ export async function reverifyFinding(findingId, note, ctx, opts = {}) {
1237
1330
  findingId,
1238
1331
  });
1239
1332
  }
1240
- await appendEvent({
1333
+ const stored = await appendEvent({
1241
1334
  session_id: ctx.sessionId,
1242
1335
  actor: ctx.actor ?? 'system',
1243
1336
  event_type: 'finding-reverified',
@@ -1251,4 +1344,5 @@ export async function reverifyFinding(findingId, note, ctx, opts = {}) {
1251
1344
  note,
1252
1345
  },
1253
1346
  }, opts);
1347
+ return { event_id: stored.event_id };
1254
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
  }
@@ -141,9 +141,26 @@ function writeBodyFile(body) {
141
141
  export function buildCreateIssueArgv(repo, title, body) {
142
142
  return ['issue', 'create', '--repo', repo, '--title', title, '--body-file', writeBodyFile(body)];
143
143
  }
144
- /** Pure builder: the argv for `gh issue list` restricted to open issues matching `query`. */
144
+ /**
145
+ * Pure builder: the argv for `gh issue list` restricted to open issues
146
+ * matching `query`. Without --json, gh prints a human-readable table that
147
+ * parseSearchResult can never parse as JSON, folding a real search into a
148
+ * generic failure indistinguishable from an actual nonzero exit. The fields
149
+ * requested here are exactly the ones parseSearchResult reads off each item.
150
+ */
145
151
  export function buildSearchIssuesArgv(repo, query) {
146
- return ['issue', 'list', '--repo', repo, '--state', 'open', '--search', query];
152
+ return [
153
+ 'issue',
154
+ 'list',
155
+ '--repo',
156
+ repo,
157
+ '--state',
158
+ 'open',
159
+ '--json',
160
+ 'number,body,url',
161
+ '--search',
162
+ query,
163
+ ];
147
164
  }
148
165
  /** Pure builder: the argv for `gh issue comment`. Spawns nothing. */
149
166
  export function buildCommentArgv(repo, issueNumber, body) {
@@ -44,7 +44,11 @@ export function redactCredentials(text) {
44
44
  return colon === -1 ? `${scheme}***@` : `${scheme}${userinfo.slice(0, colon)}:***@`;
45
45
  });
46
46
  }
47
- function exec(cwd, args) {
47
+ /**
48
+ * `onConflict`, when given, reads what git said from stdout on exit 1 — for
49
+ * the commands (merge-tree) that report a conflict there, not on stderr.
50
+ */
51
+ function exec(cwd, args, onConflict) {
48
52
  try {
49
53
  return execFileSync('git', args, {
50
54
  cwd,
@@ -57,7 +61,9 @@ function exec(cwd, args) {
57
61
  // A child that never spawned has no exit code; `code` carries the errno
58
62
  // string instead, and that is the only thing worth reporting.
59
63
  const status = typeof e.status === 'number' ? e.status : null;
60
- const said = e.stderr ?? (typeof e.code === 'string' ? `git could not be run (${e.code})` : '');
64
+ const said = onConflict !== undefined && status === 1
65
+ ? onConflict(e.stdout ?? '')
66
+ : (e.stderr ?? (typeof e.code === 'string' ? `git could not be run (${e.code})` : ''));
61
67
  throw new GitCommandError(cwd, args, status, redactCredentials(said));
62
68
  }
63
69
  }
@@ -72,6 +78,18 @@ export function runGit(cwd, args) {
72
78
  export function runGitRaw(cwd, args) {
73
79
  return exec(cwd, args);
74
80
  }
81
+ /**
82
+ * `git merge-tree --write-tree` of two commits: the merged tree's id, with no
83
+ * working tree or index touched. A conflict (exit 1) throws GitCommandError
84
+ * carrying the conflicted paths and git's CONFLICT messages, which merge-tree
85
+ * prints on stdout rather than stderr.
86
+ */
87
+ export function writeMergeTree(cwd, base, head) {
88
+ const args = ['merge-tree', '--write-tree', '--name-only', base, head];
89
+ // stdout on a conflict is the would-be tree id, then what conflicted.
90
+ const out = exec(cwd, args, (stdout) => stdout.split('\n').slice(1).join('\n'));
91
+ return out.split('\n')[0];
92
+ }
75
93
  /**
76
94
  * The `origin` remote's URL, exactly as git reports it — credentials and
77
95
  * all, since redaction happens only on the error path inside `exec`. Throws
@@ -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 [];