@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,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { constants as osConstants } from 'node:os';
3
+ import { BS_PREFIX, LEGACY_PREFIX } from './env.js';
3
4
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
4
5
  const TAIL_LINES = 50;
5
6
  /**
@@ -63,27 +64,29 @@ function signalNote(code, signal) {
63
64
  * factory's own namespace.
64
65
  *
65
66
  * A command the factory launches on a project's behalf belongs to the
66
- * project, not to the factory that launched it. Every `SMITH_*` variable in
67
- * this process was set for *this* process, and letting one through silently
68
- * makes the factory's own invocation part of the project's verdict.
69
- * `SMITH_HOME` is the one that bites: it moves `WORK_ROOT` off the worktree,
70
- * so a suite that asserts its own layout fails on the launcher's environment
71
- * alone, and anything the suite writes to state lands in the factory's live
72
- * `state/` instead of the worktree's. The verdict then depends on how the
73
- * factory happened to be invoked, which is not a verdict about the branch.
67
+ * project, not to the factory that launched it. Every `BS_*` variable — and,
68
+ * for the transition period, every legacy `SMITH_*` one — in this process
69
+ * was set for *this* process, and letting one through silently makes the
70
+ * factory's own invocation part of the project's verdict. `BS_HOME` (nee
71
+ * `SMITH_HOME`) is the one that bites: it moves `WORK_ROOT` off the
72
+ * worktree, so a suite that asserts its own layout fails on the launcher's
73
+ * environment alone, and anything the suite writes to state lands in the
74
+ * factory's live `state/` instead of the worktree's. The verdict then
75
+ * depends on how the factory happened to be invoked, which is not a verdict
76
+ * about the branch.
74
77
  *
75
78
  * The strip is a rule rather than a roster, deliberately: a roster is a list
76
- * that goes stale the next time a `SMITH_*` variable is added, and it goes
77
- * stale silently. Not every one of them is the factory configuring itself —
78
- * `SMITH_CROSSCHECK_OFFLINE` is an operator switch — but the runbook already
79
- * says of that one "pass it per command"
79
+ * that goes stale the next time a `BS_*`/`SMITH_*` variable is added, and it
80
+ * goes stale silently. Not every one of them is the factory configuring
81
+ * itself — `BS_CROSSCHECK_OFFLINE` is an operator switch — but the runbook
82
+ * already says of that one "pass it per command"
80
83
  * (`docs/runbooks/providers.md`, and it names an ambient copy of that switch
81
84
  * as its own hazard). The command the operator means is the one running
82
85
  * here, not the factory that spawned it, and stating it inside the command
83
86
  * string works: the shell applies it after this strip.
84
87
  */
85
88
  export function projectCommandEnv(env) {
86
- return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith('SMITH_')));
89
+ return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith(BS_PREFIX) && !name.startsWith(LEGACY_PREFIX)));
87
90
  }
88
91
  /**
89
92
  * Run one check command in its own process group (POSIX `setsid`, via
@@ -0,0 +1,83 @@
1
+ import { resolveArtifactPath } from './artifacts.js';
2
+ import { eventTaskId, readLineageEvents } from './events.js';
3
+ import { foldJudgeTurns, JUDGE_DISPATCH_EVENT_TYPE } from './judges.js';
4
+ import { taskIdsMatch } from './taskId.js';
5
+ /** `judges.ts`'s uiux role, spelled out here rather than imported: the string is the taxonomy value, not an implementation detail of judges.ts. */
6
+ const UIUX_ROLE = 'uiux';
7
+ /** desktop × mobile, light × dark — the four shots a visual pass has to cover. */
8
+ const REQUIRED_COMBOS = [
9
+ ['desktop', 'light'],
10
+ ['desktop', 'dark'],
11
+ ['mobile', 'light'],
12
+ ['mobile', 'dark'],
13
+ ];
14
+ function latestTesterRecord(events, taskId) {
15
+ let latest = null;
16
+ events.forEach(({ record }, index) => {
17
+ if (record.event_type !== 'artifact-check-result')
18
+ return;
19
+ if (!taskIdsMatch(eventTaskId(record) ?? undefined, taskId))
20
+ return;
21
+ const head = record.payload.head;
22
+ if (typeof head !== 'string')
23
+ return; // an artifact-check-result with no --worktree behind it
24
+ const screenshots = Array.isArray(record.payload.screenshots)
25
+ ? record.payload.screenshots
26
+ : [];
27
+ latest = { index, head, screenshots };
28
+ });
29
+ return latest;
30
+ }
31
+ /** The lineage position of the dispatch that opened a judge turn — `JudgeTurn.dispatchedAt` is wall-clock only. */
32
+ function dispatchIndex(events, taskId, role, kind, round) {
33
+ let found = -1;
34
+ events.forEach(({ record }, index) => {
35
+ if (record.event_type !== JUDGE_DISPATCH_EVENT_TYPE)
36
+ return;
37
+ if (!taskIdsMatch(eventTaskId(record) ?? undefined, taskId))
38
+ return;
39
+ if (record.payload.agent_role !== role)
40
+ return;
41
+ if (record.payload.judge_kind !== kind)
42
+ return;
43
+ if (record.payload.round !== round)
44
+ return;
45
+ found = index;
46
+ });
47
+ return found;
48
+ }
49
+ /**
50
+ * `checkUiux`: the one function both the gate stage and `bs uiux check`
51
+ * (the preflight verb, D4) call, so the two can never disagree about the same
52
+ * fixture. Reads the lineage once and folds both judge turns and the tester's
53
+ * freshness record out of it — no separate dispatch, no separate store.
54
+ */
55
+ export async function checkUiux(input, ctx, opts = {}) {
56
+ const events = await readLineageEvents(ctx.sessionId, opts);
57
+ const turns = foldJudgeTurns(events, input.taskId);
58
+ const specTurn = turns.find((t) => t.role === UIUX_ROLE && t.kind === 'spec');
59
+ if (!specTurn?.reported) {
60
+ return { outcome: 'blocked', reason: 'uiux-spec-missing' };
61
+ }
62
+ const tester = latestTesterRecord(events, input.taskId);
63
+ if (tester === null) {
64
+ return { outcome: 'blocked', reason: 'screenshots-missing' };
65
+ }
66
+ const haveCombos = new Set(tester.screenshots.map((s) => `${s.viewport}:${s.theme}`));
67
+ const allCombosPresent = REQUIRED_COMBOS.every(([viewport, theme]) => haveCombos.has(`${viewport}:${theme}`));
68
+ const allOnDisk = tester.screenshots.every((s) => resolveArtifactPath(input.taskId, s.path, input.artifactsDir) !== null);
69
+ if (!allCombosPresent || !allOnDisk) {
70
+ return { outcome: 'blocked', reason: 'screenshots-missing' };
71
+ }
72
+ if (input.head !== undefined && tester.head !== input.head) {
73
+ return { outcome: 'blocked', reason: 'screenshots-stale' };
74
+ }
75
+ const visualTurn = turns.find((t) => t.role === UIUX_ROLE && t.kind === 'visual');
76
+ const visualDispatchIndex = visualTurn === undefined
77
+ ? -1
78
+ : dispatchIndex(events, input.taskId, UIUX_ROLE, 'visual', visualTurn.round);
79
+ if (!visualTurn?.reported || !(visualDispatchIndex > tester.index)) {
80
+ return { outcome: 'blocked', reason: 'uiux-visual-missing' };
81
+ }
82
+ return { outcome: 'pass' };
83
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * What `smith` can be asked to do, written down once (P9-21).
2
+ * What `bs` can be asked to do, written down once (P9-21).
3
3
  *
4
4
  * Before this, `smith` and `smith --help` both answered
5
5
  * `{"error":{"message":"Unknown command: "}}`, so the only way to learn a
@@ -67,7 +67,7 @@ export const COMMANDS = [
67
67
  command: 'audit resolve',
68
68
  positionals: '<project-dir>',
69
69
  flags: `--epic <epic-id> [--except <fp[,fp...]>] [--plan <plan.json>] [--specs-dir <dir>] ${EVENTS_DIR}`,
70
- summary: "Append a fixed line for every carried finding the epic's newest plan still claims; list the rest as deferred. Run from the epic-close step.",
70
+ summary: "Append a fixed line for every carried finding a task in the epic's newest plan still claims, or a superseded task's change already merged; list the rest as deferred. Run from the epic-close step.",
71
71
  },
72
72
  {
73
73
  command: 'audit close',
@@ -90,14 +90,14 @@ export const COMMANDS = [
90
90
  {
91
91
  command: 'plan quorum',
92
92
  positionals: '',
93
- flags: '--plan-version <n> --session <id> --causal-parent <event-id> [--epic <id>] [--plan <draft.json>] [--confidence <0-1>] [--out <file>] [--actor <name>] [--specs-dir <dir>] [--state-dir <dir>]',
93
+ flags: '--plan-version <n> --session <id> --causal-parent <event-id> [--epic <id>] [--plan <draft.json>] [--confidence <0-1>] [--project <dir>] [--out <file>] [--actor <name>] [--specs-dir <dir>] [--state-dir <dir>]',
94
94
  summary: 'Critique-only review of a drafted plan: --plan <draft.json> before it is filed, or --epic for the filed version. Exit 1 means the operator must look first.',
95
95
  },
96
96
  {
97
97
  command: 'plan amend',
98
98
  positionals: '',
99
99
  flags: `--plan <plan.json> --findings <id[,id...]> --rationale <text> --sites <path[,path...]> [--changes <changes.json>] [--specs-dir <dir>] ${EVENTS_DIR}`,
100
- summary: 'Cut a new plan version against the spec findings that forced it. No finding, no amendment. --sites is every place the shape occurs, not only where it was reported.',
100
+ summary: 'Cut a new plan version against the spec findings that forced it. No finding, no amendment. --sites is every place the shape occurs, not only where it was reported. --specs-dir defaults to the directory --plan already lives in (#219), not factory/specs/active; an explicit --specs-dir that disagrees with it is refused rather than silently preferred.',
101
101
  },
102
102
  {
103
103
  command: 'plan propose',
@@ -126,8 +126,11 @@ export const COMMANDS = [
126
126
  {
127
127
  command: 'plan ingest',
128
128
  positionals: '<plan.json>',
129
- flags: EVENTS_DIR,
130
- summary: 'Record every task and dependency the plan declares, before any wave names them.',
129
+ flags: `[--project <dir>] ${EVENTS_DIR}`,
130
+ summary: 'Record every task and dependency the plan declares, before any wave names them. ' +
131
+ '--project resolves the branch prefix a legacy `smith/<epic>/integration` epic keeps ' +
132
+ 'using, rather than declaring `bs/...` for a branch `worktree create` will cut as ' +
133
+ '`smith/...`.',
131
134
  },
132
135
  {
133
136
  command: 'wave check',
@@ -275,8 +278,10 @@ export const COMMANDS = [
275
278
  {
276
279
  command: 'queue run',
277
280
  positionals: '<epic>',
278
- flags: '--project <dir> --test-cmd <cmd> --tasks <tasks.json> [--select-test-cmd <cmd>] [--batch] [--session <id> --causal-parent <event-id> --plan <plan.json> [--plan-version <n>] [--actor <name>]] [--state-dir <dir>]',
281
+ flags: '--project <dir> --test-cmd <cmd> --tasks <tasks.json> --session <id> --causal-parent <event-id> --plan <plan.json> [--plan-version <n>] [--actor <name>] [--select-test-cmd <cmd>] [--batch] [--state-dir <dir>]',
279
282
  summary: 'Serially rebase, test, and merge task branches into the integration branch. ' +
283
+ 'A merge is a fact the log must carry, so --session/--causal-parent/--plan are required — ' +
284
+ 'refuses before any git write without them. ' +
280
285
  '--batch (roadmap merge-lanes) groups claim-disjoint tasks with groupForBatch and ' +
281
286
  'tests each group once instead of once per task — see queue.ts and the operator guide.',
282
287
  },
@@ -293,8 +298,8 @@ export const COMMANDS = [
293
298
  {
294
299
  command: 'session start',
295
300
  positionals: '<session-id>',
296
- flags: '[--continues <event-id>] [--actor <name>] [--state-dir <dir>]',
297
- summary: 'Open a session log with the one root event it is allowed. Exit 1 if the session is already open.',
301
+ flags: '[--continues <event-id>] [--prompt <event-id>] [--actor <name>] [--state-dir <dir>]',
302
+ summary: 'Open a session log with the one root event it is allowed; --prompt links the user_prompt that asked for it. Exit 1 if the session is already open.',
298
303
  },
299
304
  {
300
305
  command: 'event append',
@@ -327,6 +332,36 @@ export const COMMANDS = [
327
332
  flags: EVENTS_DIR,
328
333
  summary: "Put the operator's own words on the timeline verbatim, and print the event id a dispatch hangs off.",
329
334
  },
335
+ {
336
+ command: 'prompt capture',
337
+ positionals: '',
338
+ flags: '',
339
+ summary: 'Read one UserPromptSubmit hook payload on stdin and record it where the cwd is managed. Print one line, or nothing; always exit 0.',
340
+ },
341
+ {
342
+ command: 'prompt import',
343
+ positionals: '',
344
+ flags: '--transcript <file> [--dry-run]',
345
+ summary: "Replay a Claude Code transcript's prompts and option answers into prompts-<cli session>, keeping their timestamps. Re-running imports nothing; --dry-run prints each event and writes none. Print a summary line.",
346
+ },
347
+ {
348
+ command: 'feedback record',
349
+ positionals: '',
350
+ flags: `--task <task-id> [--body <text>|--body-file <file>] [--kind <must-fix|nice-to-have>] [--source <dashboard|github|cli>] [--external-id <id>] [--author <name>] ${EVENTS_DIR}`,
351
+ summary: "Record the operator's mid-run comment on a task. Print { event_id, feedback_id, deduped }; a repeated --external-id dedupes rather than appending.",
352
+ },
353
+ {
354
+ command: 'feedback pending',
355
+ positionals: '',
356
+ flags: '--session <id> [--task <task-id>] [--epic <id>] [--state-dir <dir>]',
357
+ summary: "List this session lineage's unresolved feedback, each with a suggestedAction: follow-up once its task is closed to further work, bounce otherwise.",
358
+ },
359
+ {
360
+ command: 'feedback resolve',
361
+ positionals: '',
362
+ flags: `--feedback <id> --resolution <bounced|follow-up|dismissed> [--note <text>] [--follow-up-task <task-id>] ${EVENTS_DIR}`,
363
+ summary: 'Resolve one recorded feedback item. Rejects an unknown or already-resolved feedback id.',
364
+ },
330
365
  {
331
366
  command: 'claims check',
332
367
  form: '--roots',
@@ -423,7 +458,7 @@ export const COMMANDS = [
423
458
  {
424
459
  command: 'harness plan',
425
460
  positionals: '',
426
- flags: '--role <role> --task <id> --prompt-file <path> [--harness <name>] [--worktree <dir>] [--policy <file>] [--tier <frontier|mid|small>] [--schema <name>]',
461
+ flags: '--role <role> --task <id> --prompt-file <path> [--harness <name>] [--worktree <dir>] [--policy <file>] [--tier <frontier|mid|small>] [--schema <name>] [--specs-dir <dir>]',
427
462
  summary: 'Render the invocation that would run one worker turn, as JSON. Prints it; never starts it.',
428
463
  },
429
464
  {
@@ -460,7 +495,7 @@ export const COMMANDS = [
460
495
  command: 'stack check',
461
496
  positionals: '',
462
497
  flags: '[--policy <file>]',
463
- summary: 'Which stack answers the shipped templates honour, which they only record, and which make `smith new` refuse. Exit 1 on a refusal.',
498
+ summary: 'Which stack answers the shipped templates honour, which they only record, and which make `bs new` refuse. Exit 1 on a refusal.',
464
499
  },
465
500
  {
466
501
  command: 'policy check',
@@ -477,9 +512,21 @@ export const COMMANDS = [
477
512
  {
478
513
  command: 'gate run',
479
514
  positionals: '<task-id>',
480
- flags: `--worktree <dir> --checks <checks.json> --result <result.json> [--base <ref>] [--grader <file>] [--agent <role> --provider <name> --model-tier <tier> --input-tokens <n> --output-tokens <n>] [--evidence <file> --found-by <role> [--found-by-provider <name>]] [--findings <file>] [--no-findings <role>] [--lessons <lessons.md>] [--plan <file>] [--run-all] [--artifacts-dir <dir>] ${EVENTS_DIR}`,
515
+ flags: `--worktree <dir> --checks <checks.json> --result <result.json> [--project <dir>] [--base <ref>] [--grader <file>] [--agent <role> --provider <name> --model-tier <tier> --input-tokens <n> --output-tokens <n>] [--evidence <file> --found-by <role> [--found-by-provider <name>]] [--findings <file>] [--no-findings <role>] [--uiux-visual <file>] [--lessons <lessons.md>] [--plan <file>] [--run-all] [--artifacts-dir <dir>] ${EVENTS_DIR}`,
481
516
  summary: 'The per-task gate: schema, tests, coverage, findings. Exit 1 when blocked.',
482
517
  },
518
+ {
519
+ command: 'results record',
520
+ positionals: '',
521
+ flags: `--task <task-id> --result <result.json> [--agent <role> --provider <name> --model-tier <tier> [--input-tokens <n> --output-tokens <n>]] [--artifacts-dir <dir>] [--worktree <dir>] ${EVENTS_DIR}`,
522
+ summary: "Project a worker's Result on its own, with no worktree to certify and no tests to run: schema-check, task-result-recorded, artifact-check. Wave.md's tester has no gate to run one through; this is how its screenshots reach the dashboard the moment it finishes. --worktree records that worktree's HEAD and the Result's screenshot artifacts, the freshness evidence the uiux gate stage reads back. Exit 1 when blocked.",
523
+ },
524
+ {
525
+ command: 'uiux check',
526
+ positionals: '',
527
+ flags: `--task <task-id> --plan <plan.json> [--worktree <dir>] [--artifacts-dir <dir>] ${EVENTS_DIR}`,
528
+ summary: 'Preflight for the gate uiux stage: the same checkUiux a flagged task is gated on, so a dispatcher can ask before running the whole gate. Exit 1 when blocked.',
529
+ },
483
530
  {
484
531
  command: 'coverage check',
485
532
  positionals: '<worktree-dir>',
@@ -489,7 +536,7 @@ export const COMMANDS = [
489
536
  {
490
537
  command: 'budget alarm',
491
538
  positionals: '<session-id>',
492
- flags: '[--epic <id>] [--policy <path>] [--state-dir <dir>]',
539
+ flags: '[--epic <id>] [--policy <path>] [--specs-dir <dir>] [--state-dir <dir>]',
493
540
  summary: 'Epic spend against budgets.yml’s epic.alarm_ratio. Exit 1 on a crossing OR on a record too holey to rule one out.',
494
541
  },
495
542
  {
@@ -513,8 +560,8 @@ export const COMMANDS = [
513
560
  {
514
561
  command: 'epic spec-review',
515
562
  positionals: '',
516
- flags: `--epic <id> --project <dir> --plan <plan.json> --reviewed-by <role> [--reviewed-by-provider <name>] [--evidence <file>] ${EVENTS_DIR}`,
517
- summary: 'Record the closing spec review, pinned to the integration head it was read at.',
563
+ flags: `--epic <id> --project <dir> --plan <plan.json> --reviewed-by <role> [--reviewed-by-provider <name>] [--evidence <file>] [--no-findings] ${EVENTS_DIR}`,
564
+ summary: 'Record the closing spec review, pinned to the integration head it was read at. Exactly one of --evidence or --no-findings; --no-findings attests the clean case.',
518
565
  },
519
566
  {
520
567
  command: 'epic goal',
@@ -553,8 +600,8 @@ export const COMMANDS = [
553
600
  {
554
601
  command: 'findings for-dispatch',
555
602
  positionals: '',
556
- flags: '--session <id> --plan <plan.json> --task <task-id> [--state-dir <dir>]',
557
- summary: "Print the open findings a dispatching task's own claims cover, as a prompt block.",
603
+ flags: '--session <id> [--plan <plan.json> --task <task-id>] [--state-dir <dir>]',
604
+ summary: "Print the open findings a dispatching task's own claims cover, as a prompt block; with no --plan/--task, every open finding in the epic.",
558
605
  },
559
606
  {
560
607
  command: 'findings list',
@@ -595,10 +642,20 @@ export const COMMANDS = [
595
642
  flags: EVENTS_DIR,
596
643
  summary: 'Apply a batch of operator waiver decisions.',
597
644
  },
645
+ {
646
+ command: 'waivers approve-task',
647
+ positionals: '<task-id>',
648
+ flags: `--note <why> ${EVENTS_DIR}`,
649
+ summary: 'Record the operator approval of one waived task. Without it a waived task blocks the epic close; an approval counts only for the plan version it was given at or later. Requires --actor operator.',
650
+ },
598
651
  {
599
652
  command: 'lessons candidates',
600
653
  positionals: '',
601
- flags: STATS,
654
+ // Only `candidates` takes `--state-dir` alongside STATS: it validates
655
+ // `--session` against the event log the same way `raise`/`approve`/
656
+ // `reject`/`db rebuild` do (P9-28), which is a per-session log, not a
657
+ // per-DB-query concern the other STATS-flagged commands share.
658
+ flags: `${STATS} [--state-dir <dir>]`,
602
659
  summary: 'List the lesson candidates awaiting an approve or a reject.',
603
660
  },
604
661
  {
@@ -655,20 +712,20 @@ export const COMMANDS = [
655
712
  {
656
713
  command: 'judge dispatch',
657
714
  positionals: '',
658
- flags: `--task <task-id> --role <role> --artifact <file> --model <id> [--round <n>] [--provider <name>] [--model-tier <tier>] ${EVENTS_DIR}`,
659
- summary: 'Declare the file a judge owes, so a turn that never came back is visible.',
715
+ flags: `--task <task-id> --role <role> --artifact <absolute-path> --model <id> [--round <n>] [--provider <name>] [--model-tier <tier>] [--kind <spec|visual>] ${EVENTS_DIR}`,
716
+ summary: 'Declare the file a judge owes, so a turn that never came back is visible. --kind only applies to uiux, which opens a spec turn and a visual turn per task.',
660
717
  },
661
718
  {
662
719
  command: 'judge report',
663
720
  positionals: '',
664
- flags: `--task <task-id> --role <role> [--round <n>] [--artifact <file>] [--no-findings] ${EVENTS_DIR}`,
665
- summary: 'Close a judge turn against the artifact it declared. --no-findings attests the clean case.',
721
+ flags: `--task <task-id> --role <role> [--round <n>] [--artifact <file>] [--no-findings] [--kind <spec|visual>] ${EVENTS_DIR}`,
722
+ summary: "Close a judge turn against the artifact it declared, written during this turn. --no-findings attests the clean case. --kind picks which of uiux's two open turns to close when both are open.",
666
723
  },
667
724
  {
668
725
  command: 'judge outstanding',
669
726
  positionals: '',
670
- flags: '--session <id> --task <task-id> [--state-dir <dir>]',
671
- summary: 'List the judge turns still owed on a task. Exit 1 while the list is non-empty.',
727
+ flags: '--session <id> --task <task-id> [--kind <spec|visual>] [--state-dir <dir>]',
728
+ summary: 'Print { taskId, sessionId, outstanding, count } for the judge turns still owed on a task. Exit 1 while count is non-zero.',
672
729
  },
673
730
  {
674
731
  command: 'judge escalations',
@@ -679,8 +736,8 @@ export const COMMANDS = [
679
736
  {
680
737
  command: 'judge preflight',
681
738
  positionals: '',
682
- flags: '[--policy <file>]',
683
- summary: 'Can the enabled providers be called at all, and would a promotion decide anything? Exit 1 on a provider that costs a call it cannot make.',
739
+ flags: '[--policy <file>] [--project <dir>]',
740
+ summary: 'Can the enabled providers be called at all, and would a promotion decide anything? Exit 1 on a provider that costs a call it cannot make. --project merges <dir>/.blacksmith/crosscheck.yml over the policy (OpenRouter substitution/extra judges).',
684
741
  },
685
742
  {
686
743
  command: 'judge run',
@@ -691,25 +748,25 @@ export const COMMANDS = [
691
748
  {
692
749
  command: 'crossfind request',
693
750
  positionals: '',
694
- flags: '--task <id> --diff <file> --diff-ref <ref> [--criterion <text>...] [--timeout-ms <n>] [--max-output-bytes <n>] [--max-output-tokens <n>] [--policy <file>]',
751
+ flags: '--task <id> --diff <file> --diff-ref <ref> [--criterion <text>...] [--timeout-ms <n>] [--max-output-bytes <n>] [--max-output-tokens <n>] [--policy <file>] [--project <dir>] [--worktree <dir>]',
695
752
  summary: 'Print the finder request without sending it — exactly what would leave the machine. Refuses when send_diff is false.',
696
753
  },
697
754
  {
698
755
  command: 'crossfind reconcile',
699
756
  positionals: '',
700
- flags: '--task <id> --native <findings.json> --independent <runs.json> [--policy <file>]',
757
+ flags: '--task <id> --native <findings.json> --independent <runs.json> [--policy <file>] [--project <dir>] [--worktree <dir>]',
701
758
  summary: 'Reconcile two saved finding lists offline. No provider, no log. Exit 1 when the result would change a gate.',
702
759
  },
703
760
  {
704
761
  command: 'crossfind run',
705
762
  positionals: '',
706
- flags: `--task <id> --diff <file> --diff-ref <ref> [--criterion <text>...] [--status <status>] [--timeout-ms <n>] [--max-output-bytes <n>] [--max-output-tokens <n>] [--policy <file>] ${EVENTS_DIR}`,
763
+ flags: `--task <id> --diff <file> --diff-ref <ref> [--criterion <text>...] [--status <status>] [--timeout-ms <n>] [--max-output-bytes <n>] [--max-output-tokens <n>] [--policy <file>] [--project <dir>] [--worktree <dir>] ${EVENTS_DIR}`,
707
764
  summary: 'Run the independent finder over a diff and reconcile it against the native findings. Exit 1 when the result would change a gate.',
708
765
  },
709
766
  {
710
767
  command: 'ui serve',
711
768
  positionals: '',
712
- flags: '[--port <n>] [--db <file>] [--state-dir <dir>] [--roadmap-path <file>] [--specs-dir <dir>] [--now-iso <iso>]',
769
+ flags: '[--port <n>] [--db <file>] [--state-dir <dir>] [--roadmap-path <file>] [--specs-dir <dir>] [--now-iso <iso>] [--claude-config-dir <dir>] [--store <dir>...]',
713
770
  summary: 'Serve the dashboard from the built ui/server.',
714
771
  },
715
772
  {
@@ -791,7 +848,7 @@ const COMMAND_NAMES = new Set(COMMANDS.map((doc) => doc.command));
791
848
  const NAMESPACES = new Set(COMMANDS.map(namespaceOf));
792
849
  /** The line that would have worked. Empty parts are dropped, never printed as a double space. */
793
850
  export function usageLine(doc) {
794
- return ['smith', doc.command, doc.positionals, doc.flags].filter((part) => part !== '').join(' ');
851
+ return ['bs', doc.command, doc.positionals, doc.flags].filter((part) => part !== '').join(' ');
795
852
  }
796
853
  /** The positional argument names, in order — what a missing-argument error names. */
797
854
  export function positionalNames(doc) {
@@ -833,7 +890,7 @@ export function flagSpecFor(namespace, action) {
833
890
  if (namespace === undefined)
834
891
  return undefined;
835
892
  const key = action === undefined ? namespace : `${namespace} ${action}`;
836
- // The second arm is what keeps the action-less verbs working: `smith new
893
+ // The second arm is what keeps the action-less verbs working: `bs new
837
894
  // my-project` spends its action slot on a positional. Same fallback as
838
895
  // isDocumented, deliberately — a command that dispatches must also parse.
839
896
  const docs = COMMANDS.filter((doc) => doc.command === key || doc.command === namespace).filter((doc) => doc.command === key || !COMMAND_NAMES.has(key));
@@ -861,23 +918,23 @@ function renderCommands(docs) {
861
918
  }
862
919
  /**
863
920
  * The whole command list, or one namespace's slice of it. An unknown namespace
864
- * throws rather than printing an empty list: "smith has no such namespace" and
921
+ * throws rather than printing an empty list: "bs has no such namespace" and
865
922
  * "that namespace has no commands" must not be the same observable answer.
866
923
  */
867
924
  export function usageText(namespace) {
868
925
  if (namespace !== undefined && !NAMESPACES.has(namespace)) {
869
- throw new SmithError('usage.unknown-namespace', `No such namespace: ${namespace}. Run "smith --help" for the list.`, { namespace });
926
+ throw new SmithError('usage.unknown-namespace', `No such namespace: ${namespace}. Run "bs --help" for the list.`, { namespace });
870
927
  }
871
928
  const docs = namespace === undefined ? COMMANDS : COMMANDS.filter((doc) => namespaceOf(doc) === namespace);
872
929
  const header = namespace === undefined
873
930
  ? [
874
- 'Usage: smith <namespace> <action> [args] [--flags]',
931
+ 'Usage: bs <namespace> <action> [args] [--flags]',
875
932
  '',
876
- ' smith <namespace> --help list one namespace',
877
- ' smith <command> --help show one command',
933
+ ' bs <namespace> --help list one namespace',
934
+ ' bs <command> --help show one command',
878
935
  '',
879
936
  ].join('\n')
880
- : `Usage: smith ${namespace} <action> [args] [--flags]\n`;
937
+ : `Usage: bs ${namespace} <action> [args] [--flags]\n`;
881
938
  let out = `${header}\n`;
882
939
  let current = '';
883
940
  for (const doc of docs) {
@@ -903,7 +960,7 @@ export function helpText(namespace, action) {
903
960
  const exact = COMMANDS.filter((doc) => doc.command === key);
904
961
  if (exact.length > 0)
905
962
  return renderCommands(exact);
906
- // `smith new my-project --help`: the action slot holds a positional, so the
963
+ // `bs new my-project --help`: the action slot holds a positional, so the
907
964
  // namespace alone is the command.
908
965
  const bare = COMMANDS.filter((doc) => doc.command === namespace);
909
966
  if (bare.length > 0)
@@ -916,7 +973,7 @@ export function helpText(namespace, action) {
916
973
  * Whether cli.ts's dispatcher has a branch for this pair. Checked before
917
974
  * dispatch, so an undocumented command cannot run — which is the half of
918
975
  * "the table and the dispatcher agree" that a test cannot enforce at runtime.
919
- * The second arm is what keeps the action-less verbs working: `smith new
976
+ * The second arm is what keeps the action-less verbs working: `bs new
920
977
  * my-project` spends its action slot on a positional.
921
978
  */
922
979
  export function isDocumented(namespace, action) {
@@ -1,8 +1,11 @@
1
+ import { isOperatorActor } from './actors.js';
1
2
  import { SmithError } from './errors.js';
2
3
  import { appendEvent, readLineageEvents } from './events.js';
3
4
  import { computeFingerprint, foldFindings, listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
4
5
  export class WaiverError extends SmithError {
5
6
  }
7
+ /** The event `approveTaskWaiver` writes and epic.ts's withGateEvidence reads. */
8
+ export const TASK_WAIVER_APPROVED_EVENT = 'task-waiver-approved';
6
9
  /**
7
10
  * Only S3/S4 findings are ever waived (severity.yml waiver_semantics) —
8
11
  * single source of truth, reused by findings.ts's transition() so `→ waived`
@@ -129,6 +132,33 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
129
132
  await reconcileFindingsToWaived(fingerprint, waiverEvent.event_id, ctx, opts);
130
133
  return waiverEvent;
131
134
  }
135
+ /**
136
+ * Record the operator's approval of one task's waiver. A task reaches
137
+ * `waived` through its plan row, which carries no gate run; this event is the
138
+ * evidence the epic gate (epic.ts's withGateEvidence) looks for instead.
139
+ *
140
+ * Refused unless the actor is the operator (actors.ts) and a rationale is
141
+ * given: the approval is only worth anything as a person's recorded decision.
142
+ * The default actor is the one every other operator decision defaults to.
143
+ */
144
+ export async function approveTaskWaiver(taskId, operatorNote, ctx, opts = {}) {
145
+ const actor = ctx.actor ?? 'user';
146
+ if (!isOperatorActor(actor)) {
147
+ throw new WaiverError('waivers.not-operator', `Actor "${actor}" cannot approve a task waiver — only the operator can. Pass --actor operator.`, { actor, taskId });
148
+ }
149
+ if (operatorNote.trim() === '') {
150
+ throw new WaiverError('waivers.note-required', `Approving the waiver of task "${taskId}" needs a short rationale (--note).`, { taskId });
151
+ }
152
+ return appendEvent({
153
+ session_id: ctx.sessionId,
154
+ actor,
155
+ event_type: TASK_WAIVER_APPROVED_EVENT,
156
+ task_id: taskId,
157
+ plan_version: ctx.planVersion,
158
+ causal_parent: ctx.causalParent,
159
+ payload: { operator_note: operatorNote },
160
+ }, opts);
161
+ }
132
162
  /**
133
163
  * The mirror of reconcileFindingsToWaived, and it exists for the same reason
134
164
  * (D-180). A denial that follows a grant revokes it — isWaived() folds
@@ -256,7 +286,7 @@ export async function applyBatch(decisions, ctx, opts = {}) {
256
286
  `"${evidence.mergedTaskId}" has merged over since the finding was raised (matched by ` +
257
287
  `${evidence.basis}: ${evidence.matched}). Its evidence describes code that no longer ` +
258
288
  'exists, so this waiver would answer a question about a deleted file. Re-read it first: ' +
259
- `smith findings reverify --finding ${finding.finding_id}`, {
289
+ `bs findings reverify --finding ${finding.finding_id}`, {
260
290
  fingerprint: decision.fingerprint,
261
291
  findingId: finding.finding_id,
262
292
  filePath: evidence.filePath,
@@ -266,14 +296,18 @@ export async function applyBatch(decisions, ctx, opts = {}) {
266
296
  }
267
297
  }
268
298
  }
269
- const results = [];
299
+ const events = [];
300
+ const findingIdsToCarry = new Set();
270
301
  for (const decision of decisions) {
271
302
  if (decision.decision === 'granted') {
272
- results.push(await grantWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
303
+ events.push(await grantWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
273
304
  }
274
305
  else {
275
- results.push(await denyWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
306
+ events.push(await denyWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
307
+ for (const finding of findingsByFingerprint.get(decision.fingerprint) ?? []) {
308
+ findingIdsToCarry.add(finding.finding_id);
309
+ }
276
310
  }
277
311
  }
278
- return results;
312
+ return { events, findingIdsToCarry: [...findingIdsToCarry] };
279
313
  }
@@ -153,6 +153,7 @@ export function checkWaveBudget(events, policy, proposed, options) {
153
153
  if (options.epicId.trim().length === 0) {
154
154
  return {
155
155
  epicId: null,
156
+ tier: policy.tier,
156
157
  capTokens,
157
158
  measuredTokens: 0,
158
159
  projectedTokens: 0,
@@ -189,6 +190,7 @@ export function checkWaveBudget(events, policy, proposed, options) {
189
190
  const inFlight = inFlightTasks(events, epicId).length;
190
191
  const base = {
191
192
  epicId,
193
+ tier: policy.tier,
192
194
  capTokens,
193
195
  measuredTokens,
194
196
  projectedTokens,