@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
@@ -15,7 +15,8 @@
15
15
  // nothing at runtime. `test/cliBoot.test.ts` reads the built graph and fails if
16
16
  // the database layer creeps back into it.
17
17
  import { execFileSync, spawn } from 'node:child_process';
18
- import { mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync, } from 'node:fs';
19
+ import { homedir } from 'node:os';
19
20
  import path from 'node:path';
20
21
  import { pathToFileURL } from 'node:url';
21
22
  import { resetAgentMaxTurns, syncAgentMaxTurns } from './agentsSync.js';
@@ -24,20 +25,25 @@ import { closeAudit, consolidateAudit, cutAudit, decideAudit, openAudit, recordA
24
25
  import { checkBudgetAlarm } from './budgetAlarm.js';
25
26
  import { budgetEnvOverrides, loadBudgetPolicy } from './budgets.js';
26
27
  import { collectCommittedChanges, loadWorktreePolicy, postRunCheck, readClaimList, validateWave, writeRootCheck, } from './claims.js';
28
+ import { warnIfLegacyName } from './cliName.js';
27
29
  import { collectCoverageEvidence } from './coverage.js';
28
30
  import { loadCrosscheckPolicy } from './crosscheck.js';
29
31
  import { independentFinderRequest, reconcile, runIndependentFinder, } from './crossFinding.js';
32
+ import { formatDeclaredArtifactLine } from './declaredArtifactLine.js';
30
33
  import { checkDelegationGrants, checkDelegationLog, loadDelegationPolicy } from './delegation.js';
31
34
  import { checkDispatchAsymmetry } from './dispatchAudit.js';
32
35
  import { lintDispatchPrompt } from './dispatchLint.js';
33
36
  import { loadDotEnv } from './dotenv.js';
34
37
  import { loadEffortPolicy, resolveEffort } from './effort.js';
38
+ import { budgetPolicyForPlan, epicBudgetPolicies } from './epicBudget.js';
35
39
  import { ISSUE_CANDIDATE_EVENT_TYPES } from './errorIssues.js';
36
40
  import { SmithError } from './errors.js';
37
41
  import { checkEscalationLadder } from './escalation.js';
38
42
  import { appendEvent, filterEvents, followEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
43
+ import { listPendingFeedback, recordFeedback, resolveFeedback, } from './feedback.js';
39
44
  import { findingsForDispatch } from './findingContext.js';
40
45
  import { AMEND_PENDING_STATUS, AMENDED_STATUS, findingScope, listFindings, mintFindings, OPEN_FINDING_STATUSES, raiseFinding, repairObligation, reverifyFinding, SPEC_FINDING_SCOPE, transition as transitionFinding, } from './findings.js';
46
+ import { runGit } from './git.js';
41
47
  import { recordGoalCheck, resolveEpicGoal } from './goalCheck.js';
42
48
  import { loadHarnessPolicy, planWorkerTurn, summarizeHarnesses, } from './harness.js';
43
49
  import { decideHookPayload } from './hookDecision.js';
@@ -46,13 +52,15 @@ import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
46
52
  import { integrationHeadSha, runIntegrationCheck } from './integration.js';
47
53
  import { previewOutcomes, reportErrors } from './issueReporter.js';
48
54
  import { judgePreflight } from './judgePreflight.js';
49
- import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
55
+ import { isJudgeKind, JudgeError, outstandingJudges, readJudgeArtifact, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, uiuxDeviationsToEvidence, } from './judges.js';
50
56
  import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
51
- import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
52
- import { bareTaskId, diffPlans, livePlanTasks, planProjectResolverForTaskRef, planSelfFallbackForTaskRef, resolveTaskId, validatePlan, } from './plan.js';
57
+ import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, WORK_ROOT, } from './paths.js';
58
+ import { diffPlans, impliedSpecsDir, isUiAffecting, latestPlanVersion, livePlanTasks, liveSpec, loadPlan, planProjectResolverForTaskRef, planSelfFallbackForTaskRef, resolveTaskId, validatePlan, } from './plan.js';
53
59
  import { runPlanQuorum } from './planQuorum.js';
54
60
  import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
55
61
  import { factoryProjects } from './projects.js';
62
+ import { captureContext, capturePrompt } from './promptCapture.js';
63
+ import { importTranscript } from './promptImport.js';
56
64
  import { recordUserPrompt } from './prompts.js';
57
65
  import { checkBrief, wrapIngested } from './provenance.js';
58
66
  import { runJudge } from './providers/index.js';
@@ -65,20 +73,22 @@ import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './s
65
73
  import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
66
74
  import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
67
75
  import { parseLessons } from './severity.js';
68
- import { amendPlan, recordSpecReview, taskSuccessors } from './spec.js';
76
+ import { amendPlan, recordSpecReview, successorChainPath, taskSuccessors } from './spec.js';
69
77
  import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
70
78
  import { checkStack, loadStackAnswers } from './stack.js';
71
79
  import { buildSymbolGraph, collectSources } from './symbols.js';
72
- import { emitEdgesRecorded, emitTasksAdded, emitWaveAdmitted, readAddedTasks, } from './taskEvents.js';
80
+ import { emitEdgesRecorded, emitTasksAdded, emitWaveAdmitted, planIngestGaps, readAddedTasks, unadmissibleTasks, } from './taskEvents.js';
81
+ import { epicOfTaskId } from './taskId.js';
82
+ import { CLOSED_TO_FURTHER_WORK } from './taskStatus.js';
73
83
  import { loadTaxonomy } from './taxonomy.js';
74
84
  import { checkTesterIsolation } from './testerAudit.js';
75
85
  import { assertSelectableTestCmd } from './testSelect.js';
76
86
  import { COMMANDS, flagSpecFor, helpText, isDocumented, positionalNames, usageFor, usageLine, usageText, } from './usage.js';
77
- import { applyBatch, pendingBatch } from './waivers.js';
87
+ import { applyBatch, approveTaskWaiver, pendingBatch, } from './waivers.js';
78
88
  import { blocksAdmission, checkWaveBudget, } from './waveBudget.js';
79
89
  import { computeNextWave, liveWaveTasks } from './waveNext.js';
80
90
  import { initWorkRoot } from './workroot.js';
81
- import { createTaskWorktree, listStale, RESERVED_TASK_ID, removeTaskWorktree, taskBranchName, } from './worktree.js';
91
+ import { createTaskWorktree, epicBranchPrefix, integrationBranchName, listStale, RESERVED_TASK_ID, removeTaskWorktree, taskBranchExists, taskBranchName, } from './worktree.js';
82
92
  function printJson(value) {
83
93
  process.stdout.write(`${JSON.stringify(value)}\n`);
84
94
  }
@@ -96,6 +106,38 @@ function readJsonFile(filePath) {
96
106
  * at the point the file is read, rather than let a gate run act on either.
97
107
  */
98
108
  const MAX_CHECK_TIMEOUT_MS = 2_147_483_647;
109
+ /**
110
+ * `--findings <file>`'s intake, both places it's read (`findings raise`,
111
+ * `gate run`). The file is a pre-minted `RaiseFindingInput[]` (`{finding,
112
+ * filePath}`) — a replay, a fixture, a cross-check re-run — not the
113
+ * `FindingEvidence[]` (`{file_path, finding_category, ...}`, snake_case) a
114
+ * judge artifact actually holds, the shape `--evidence` expects. Reading it
115
+ * unvalidated used to crash deep inside routing: `normalizeRepoPath` reads
116
+ * `filePath` straight off the item, so a judge artifact's `undefined`
117
+ * `filePath` threw a raw TypeError (#285) instead of naming what was wrong.
118
+ */
119
+ function readFindingsInputFile(filePath) {
120
+ const items = readJsonFile(filePath);
121
+ if (!Array.isArray(items)) {
122
+ throw new SmithError('cli.invalid-flag', `--findings ${filePath} must be a JSON array of {finding, filePath} items, got ${typeof items}.`, { flag: 'findings', file: filePath });
123
+ }
124
+ items.forEach((item, index) => {
125
+ const record = (item && typeof item === 'object' ? item : {});
126
+ const looksLikeEvidence = typeof record.file_path === 'string' &&
127
+ typeof record.finding_category === 'string' &&
128
+ record.finding === undefined;
129
+ if (looksLikeEvidence) {
130
+ throw new SmithError('cli.invalid-flag', `--findings ${filePath} item ${index} looks like a judge artifact (FindingEvidence: file_path/finding_category), not a raised finding ({finding, filePath}). Use --evidence <file> --found-by <role> instead.`, { flag: 'findings', file: filePath, index });
131
+ }
132
+ if (typeof record.filePath !== 'string') {
133
+ throw new SmithError('cli.invalid-flag', `--findings ${filePath} item ${index} is missing a string "filePath".`, { flag: 'findings', file: filePath, index, field: 'filePath' });
134
+ }
135
+ if (record.finding === null || typeof record.finding !== 'object') {
136
+ throw new SmithError('cli.invalid-flag', `--findings ${filePath} item ${index} is missing a "finding" object.`, { flag: 'findings', file: filePath, index, field: 'finding' });
137
+ }
138
+ });
139
+ return items;
140
+ }
99
141
  function readChecksFile(filePath) {
100
142
  const checks = readJsonFile(filePath);
101
143
  for (const check of checks) {
@@ -166,6 +208,9 @@ function declaredWaveBudgets(plan, taskIds, policy) {
166
208
  function admissionBudget(check, rationale) {
167
209
  return {
168
210
  status: check.status,
211
+ // Threaded from the policy the cap came from, never re-resolved here: a
212
+ // second resolution is a second answer that can drift from the cap's.
213
+ tier: check.tier,
169
214
  cap_tokens: check.capTokens,
170
215
  projected_tokens: check.projectedTokens,
171
216
  wave_tokens: check.waveTokens,
@@ -181,23 +226,41 @@ function requireFlag(flags, name) {
181
226
  return value;
182
227
  }
183
228
  /**
184
- * requireFlag for a flag that must parse as a number, so `--input-tokens abc`
185
- * is named here rather than travelling as NaN into a token_usage the schema
186
- * then rejects for the wrong reason.
229
+ * Resolves the project directory an OPTIONAL --project reads its
230
+ * `.blacksmith/crosscheck.yml` overlay from, for the verbs where --project
231
+ * is not required outright (unlike `epic verdict`/`epic close`, D-42/P9-26).
232
+ * --project wins when given; otherwise, when a --worktree is present, its
233
+ * main checkout is derived, because a task worktree is a git-worktree
234
+ * SIBLING of the project rather than a child (`.wt/<project>/<task>`, per
235
+ * `taskWorktreeDir()`), so `path.dirname()` cannot recover it -- only git
236
+ * knows the common dir a worktree branches off of.
237
+ *
238
+ * Returns undefined when neither flag is present or the worktree is not a
239
+ * git checkout yet, which is deliberate: `loadCrosscheckPolicy()` already
240
+ * treats an absent projectDir as "no overlay", so callers that spread this
241
+ * in conditionally keep the env+shipped-policy behaviour unchanged rather
242
+ * than regressing every call site that has no project to give.
187
243
  */
188
- function requireIntFlag(flags, name) {
189
- const raw = requireFlag(flags, name);
190
- const value = Number(raw);
191
- if (!Number.isInteger(value)) {
192
- throw new SmithError('cli.non-numeric-flag', `--${name} must be an integer, got "${raw}".`, {
193
- flag: name,
194
- value: raw,
195
- });
244
+ function projectDirFromFlags(flags, worktreeFlagName = 'worktree') {
245
+ if (flags.project)
246
+ return flags.project;
247
+ const worktreeDir = flags[worktreeFlagName];
248
+ if (!worktreeDir)
249
+ return undefined;
250
+ try {
251
+ const gitCommonDir = runGit(worktreeDir, ['rev-parse', '--git-common-dir']);
252
+ return path.dirname(path.resolve(worktreeDir, gitCommonDir));
253
+ }
254
+ catch {
255
+ return undefined;
196
256
  }
197
- return value;
198
257
  }
199
258
  /**
200
- * requireIntFlag's optional twin, for a count that also has to be in range.
259
+ * A flag that must parse as a whole number in range, but may also be absent
260
+ * entirely — the both-or-neither shape `--input-tokens`/`--output-tokens`
261
+ * need (#220): a dispatcher with no API onto a subagent's token spend omits
262
+ * both rather than travelling a typo'd or invented number as NaN into a
263
+ * token_usage the schema then rejects for the wrong reason.
201
264
  *
202
265
  * D-210. `event tail --n` read its count with a bare Number.parseInt and
203
266
  * checked nothing, which fails in both directions at once: `--n abc` is NaN,
@@ -435,6 +498,70 @@ async function issueInputs(flags) {
435
498
  function planOptsFromFlags(flags) {
436
499
  return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
437
500
  }
501
+ /** `realpathSync`, falling back to the resolved lexical path when it fails. */
502
+ function realOrResolved(p) {
503
+ try {
504
+ return realpathSync(p);
505
+ }
506
+ catch {
507
+ return path.resolve(p);
508
+ }
509
+ }
510
+ /**
511
+ * `plan amend`'s own specs-dir default (#219) -- deliberately not
512
+ * `planOptsFromFlags`, whose silent fallback to `SPECS_ACTIVE_DIR` is a
513
+ * documented contract several other commands rely on for a READ (see its
514
+ * own doc comment, and `issueInputs`'s). Amend is a WRITE: the version it
515
+ * cuts is filed at `<specsDir>/<epicId>/plan-vN.json`, so silently
516
+ * defaulting to this checkout's own specs tree when `--plan` already lives
517
+ * somewhere else does not merely read the wrong file, it plants the new
518
+ * version in the wrong one -- beside no other version of the plan it just
519
+ * amended.
520
+ *
521
+ * Derived from `--plan`'s own directory instead (`impliedSpecsDir`), when
522
+ * that path already sits in the shape a plan file lives in. An explicit
523
+ * `--specs-dir` still wins outright when it agrees with that; when it does
524
+ * not, the two flags are answering a question this command cannot silently
525
+ * referee, so it refuses rather than guessing which one the caller meant.
526
+ */
527
+ function amendSpecsDirOpts(planPath, plan, flags) {
528
+ const explicit = flags['specs-dir'];
529
+ const implied = impliedSpecsDir(planPath, plan.epic_id);
530
+ if (explicit === undefined)
531
+ return implied === null ? {} : { specsDir: implied };
532
+ // Compared on disk, not lexically: the same tree reached through a
533
+ // symlink (macOS /tmp -> /private/tmp) is agreement, not a mismatch.
534
+ if (implied !== null && realOrResolved(explicit) !== realOrResolved(implied)) {
535
+ throw new SmithError('cli.specs-dir-mismatch', `--specs-dir "${explicit}" disagrees with the directory --plan "${planPath}" already lives in: that plan's own layout says its specs dir is "${implied}". Drop --specs-dir to amend in place, point it at "${implied}", or move --plan under the tree --specs-dir names.`, { plan: planPath, epicId: plan.epic_id, specsDir: explicit, impliedSpecsDir: implied });
536
+ }
537
+ return { specsDir: explicit };
538
+ }
539
+ /**
540
+ * The tier `epic verdict`/`epic close` actually run at, resolved the same way
541
+ * `effort show` resolves it (security floor included) so the two commands
542
+ * can never disagree about what tier an epic is on. Read-only, best-effort on
543
+ * the plan the way `resolvePlanRoster` (epic.ts) is: a plan that fails to
544
+ * load is "unreadable", not a crash, and `resolveEffort` already treats an
545
+ * absent plan as "floor not evaluated" rather than "floor clean" (see
546
+ * `EffortResolution.securityFloorEvaluated`).
547
+ */
548
+ function resolveEpicEffort(epicId, flags) {
549
+ const planOpts = planOptsFromFlags(flags);
550
+ const version = latestPlanVersion(epicId, planOpts);
551
+ let plan = null;
552
+ if (version !== null) {
553
+ try {
554
+ plan = loadPlan(epicId, version, planOpts);
555
+ }
556
+ catch {
557
+ plan = null;
558
+ }
559
+ }
560
+ const policy = loadEffortPolicy(flags.policy);
561
+ const securityPolicy = loadCrosscheckPolicy(flags.crosscheck).planQuorum;
562
+ const resolution = resolveEffort(policy, securityPolicy, plan ? { plan } : {});
563
+ return { tier: resolution.effective, closingSpecReview: resolution.profile.closingSpecReview };
564
+ }
438
565
  /**
439
566
  * The one reader for every flag usage.ts documents as `<iso>` (D-209).
440
567
  *
@@ -491,10 +618,10 @@ function isoDateFlag(flags, name) {
491
618
  * `event append` writes.
492
619
  *
493
620
  * `--roadmap-path` belongs here too: both verbs rebuild the whole milestones
494
- * table from a roadmap file, and unset it falls back to black-smith's own
621
+ * table from a roadmap file, and unset it falls back to blacksmith's own
495
622
  * factory/specs/roadmap.md. Dropping the flag therefore did not mean "keep the
496
623
  * existing milestones" — it meant "replace this db's milestones with
497
- * black-smith's", which is a silent data swap for any db but this repo's.
624
+ * blacksmith's", which is a silent data swap for any db but this repo's.
498
625
  *
499
626
  * `--specs-dir` travels for the same reason and is the same flag `plan`
500
627
  * already takes: the projector reads an epic's plan file to answer what
@@ -662,6 +789,25 @@ function mintFromEvidence(args, taskId, scope = {}) {
662
789
  ...scope,
663
790
  }));
664
791
  }
792
+ /**
793
+ * Mint a uiux visual pass's deviations under the `uiux` judge. `--uiux-visual`
794
+ * hands over the whole Result document (bs-uiux.md's output contract), not a
795
+ * findings-evidence array, so this reads `structured_output.deviations` the
796
+ * same way `readJudgeArtifact`'s uiux branch does (judges.ts) before handing
797
+ * the mapped evidence to the same `mintFindings` every other judge goes
798
+ * through.
799
+ */
800
+ function mintFromUiuxVisual(filePath, taskId) {
801
+ // Reuse `readJudgeArtifact`'s uiux visual-branch validation (judges.ts)
802
+ // rather than re-checking the shape here: a document missing
803
+ // `structured_output.deviations`, or one where it is not an array, must
804
+ // raise `judges.artifact-invalid-evidence` instead of silently minting
805
+ // nothing and letting the gate pass on a wrong-shaped document (S2).
806
+ readJudgeArtifact(filePath, 'uiux', taskId, 'visual');
807
+ const document = readJsonFile(filePath);
808
+ const deviations = document.structured_output?.deviations ?? [];
809
+ return mintFindings(uiuxDeviationsToEvidence(deviations), { taskId, foundBy: 'uiux' });
810
+ }
665
811
  /**
666
812
  * The gated task's declared caps, read off the same `--plan` the gate already
667
813
  * takes for ownership (D-41/P9-24) — no new flag, because a budget the gate can
@@ -686,9 +832,33 @@ function budgetFromFlags(flags, taskId) {
686
832
  return undefined;
687
833
  throw err;
688
834
  }
689
- const budget = plan.tasks.find((t) => t.task_id === resolved)?.budget;
835
+ const budget = liveSpec(plan, resolved)?.budget;
690
836
  return typeof budget === 'object' && budget !== null ? budget : undefined;
691
837
  }
838
+ /**
839
+ * The task's `ui_affecting` flag off `--plan` (U2 D3/D6), for the gate's uiux
840
+ * stage: `undefined` means no `--plan` was given at all, so the stage cannot
841
+ * resolve the flag either way and records "unverifiable" rather than guess
842
+ * (D6). A `--plan` that does not name the task, same as `budgetFromFlags`,
843
+ * is read as `undefined` too — a follow-up task no plan version has been cut
844
+ * for yet is not grounds to force the uiux stage one way or the other.
845
+ */
846
+ function uiAffectingFromFlags(flags, taskId) {
847
+ if (!flags.plan)
848
+ return undefined;
849
+ const plan = readJsonFile(flags.plan);
850
+ let resolved;
851
+ try {
852
+ resolved = resolveTaskId(plan, taskId);
853
+ }
854
+ catch (err) {
855
+ if (err instanceof SmithError && err.code === 'plan.unknown-task')
856
+ return undefined;
857
+ throw err;
858
+ }
859
+ const spec = liveSpec(plan, resolved);
860
+ return spec ? isUiAffecting(spec) : undefined;
861
+ }
692
862
  /**
693
863
  * The claim globs one dispatch is scoped to, read off the plan (P9-2). Only
694
864
  * claim-path-scoped lessons use them, so a role that declares no such scope
@@ -700,7 +870,21 @@ function claimsForDispatch(flags) {
700
870
  if (!flags.plan)
701
871
  return [];
702
872
  const taskId = requireFlag(flags, 'task');
703
- const owner = ownershipFromPlan(readJsonFile(flags.plan)).find((t) => t.task_id === taskId);
873
+ const plan = readJsonFile(flags.plan);
874
+ let resolved;
875
+ try {
876
+ resolved = resolveTaskId(plan, taskId);
877
+ }
878
+ catch (err) {
879
+ if (err instanceof SmithError && err.code === 'plan.unknown-task') {
880
+ throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
881
+ taskId,
882
+ plan: flags.plan,
883
+ });
884
+ }
885
+ throw err;
886
+ }
887
+ const owner = ownershipFromPlan(plan).find((t) => t.task_id === resolved);
704
888
  if (!owner) {
705
889
  throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
706
890
  taskId,
@@ -724,7 +908,21 @@ function caseForDispatch(flags) {
724
908
  if (!flags.plan)
725
909
  return '';
726
910
  const taskId = requireFlag(flags, 'task');
727
- const task = readJsonFile(flags.plan).tasks.find((t) => t.task_id === taskId);
911
+ const plan = readJsonFile(flags.plan);
912
+ let resolved;
913
+ try {
914
+ resolved = resolveTaskId(plan, taskId);
915
+ }
916
+ catch (err) {
917
+ if (err instanceof SmithError && err.code === 'plan.unknown-task') {
918
+ throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
919
+ taskId,
920
+ plan: flags.plan,
921
+ });
922
+ }
923
+ throw err;
924
+ }
925
+ const task = liveSpec(plan, resolved);
728
926
  if (!task) {
729
927
  throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
730
928
  taskId,
@@ -782,6 +980,47 @@ function splitNamespaceAction(argv) {
782
980
  }
783
981
  return { namespace, action: undefined, rest: restAll };
784
982
  }
983
+ /**
984
+ * #250. `wave check --session` admits against the log, so before it admits
985
+ * it asks the log three things, in order: was this plan version ingested,
986
+ * does every id have a task row, and is every row still live. Each refusal is
987
+ * thrown before anything is written, so a refused wave leaves no
988
+ * `wave-admitted` behind. "Live" excludes only CLOSED_TO_FURTHER_WORK: a
989
+ * `failed` or `escalated` task is held by an operator and is re-admitted by
990
+ * the escalation ladder, so it passes.
991
+ *
992
+ * The no-row refusal is a defensive invariant, not a path the CLI reaches
993
+ * today: past an ingest every plan id has a row, and an id neither the plan
994
+ * nor the log knows is refused earlier, by the id resolution.
995
+ *
996
+ * Order matters for the answer the operator reads: an un-ingested plan makes
997
+ * every row question moot (the rows are missing or stale because the ingest
998
+ * is), so it is asked first and names the one command that fixes it.
999
+ */
1000
+ async function refuseUnadmissibleTasks(plan, taskIds, flags) {
1001
+ const sessionId = flags.session;
1002
+ const eventOpts = eventOptsFromFlags(flags);
1003
+ const gaps = await planIngestGaps(plan, { sessionId }, eventOpts);
1004
+ if (gaps.length > 0) {
1005
+ const pending = [...new Set(gaps.map((g) => g.taskId))];
1006
+ throw new SmithError('cli.plan-not-ingested', `Plan "${plan.epic_id}" v${plan.version} has not been ingested into session "${sessionId}" (${pending.length} task(s) pending): run \`bs plan ingest\` on it first, then re-check.`, { epic: plan.epic_id, version: plan.version, session: sessionId, pending });
1007
+ }
1008
+ // Imported here for the reason nextWaveInputFrom gives: the db layer stays
1009
+ // out of the graph every `smith` invocation loads.
1010
+ const { foldTasks, taskIdCanonicalizer } = await import('./db/projector.js');
1011
+ const events = await readLineageEvents(sessionId, eventOpts);
1012
+ const statusOf = new Map();
1013
+ for (const row of foldTasks(events))
1014
+ statusOf.set(row.taskId, row.taskStatus);
1015
+ const canonical = taskIdCanonicalizer(events);
1016
+ const { missing, terminal } = unadmissibleTasks(taskIds.map(canonical), statusOf);
1017
+ if (missing.length > 0) {
1018
+ throw new SmithError('plan.unknown-task', `Session "${sessionId}" has no task row for ${missing.map((id) => `"${id}"`).join(', ')}: a wave is admitted against the log, and the log does not know ${missing.length === 1 ? 'this task' : 'these tasks'}.`, { session: sessionId, missing });
1019
+ }
1020
+ if (terminal.length > 0) {
1021
+ throw new SmithError('cli.task-not-live', `Refusing to admit ${terminal.map((t) => `"${t.taskId}" (${t.taskStatus})`).join(', ')}: the work is over in session "${sessionId}". Drop ${terminal.length === 1 ? 'it' : 'them'} from the wave.`, { session: sessionId, tasks: terminal });
1022
+ }
1023
+ }
785
1024
  /**
786
1025
  * Everything a wave computation reads about a plan: the plan itself with the
787
1026
  * log's follow-ups merged in, the worktree policy, the import crossings
@@ -869,6 +1108,10 @@ async function nextWaveInputFrom(planFile, flags) {
869
1108
  };
870
1109
  }
871
1110
  async function main() {
1111
+ // bs-rename, operator decision 3: a no-op unless argv[1]'s basename is the
1112
+ // deprecated `smith`, in which case it writes one line to stderr, never
1113
+ // stdout -- stdout is parsed as JSON by playbooks.
1114
+ warnIfLegacyName(process.argv[1], 'smith', 'bs');
872
1115
  // Refuse an unsupported runtime before anything opens the database. The
873
1116
  // native binding crashes lazily — `new Database()`, not import — so a check
874
1117
  // here still runs, and a subcommand that happens to avoid SQLite must not
@@ -878,7 +1121,7 @@ async function main() {
878
1121
  if (!runtime.supported) {
879
1122
  // Prose on stderr so a human sees it even when stdout is piped into a
880
1123
  // JSON parser; the structured form still goes to stdout for callers.
881
- process.stderr.write(`smith: unsupported runtime\n${runtime.reason ?? ''}\n`);
1124
+ process.stderr.write(`bs: unsupported runtime\n${runtime.reason ?? ''}\n`);
882
1125
  printJson({ error: { code: 'unsupported-runtime', message: runtime.reason } });
883
1126
  return 1;
884
1127
  }
@@ -1023,7 +1266,25 @@ async function main() {
1023
1266
  // requirePositionals is what makes it true, and it runs first.
1024
1267
  const [planFile] = requirePositionals(positional, usageFor('plan validate'));
1025
1268
  const plan = readJsonFile(planFile);
1026
- const result = validatePlan(plan);
1269
+ // Grandfathering (plan.ui-flag-missing, U2 S1 R2) needs the version this
1270
+ // one amends. It is readable here only when the file sits where
1271
+ // `impliedSpecsDir` expects a plan to sit (`<specsDir>/<epicId>/plan-vN.json`)
1272
+ // and the previous version is actually on disk beside it; a validate run
1273
+ // against a bare fixture or a file moved elsewhere gets no previous and
1274
+ // validates without the exemption, same as before this change.
1275
+ let previous;
1276
+ if (plan.version > 1) {
1277
+ const specsDir = impliedSpecsDir(planFile, plan.epic_id);
1278
+ if (specsDir !== null) {
1279
+ try {
1280
+ previous = loadPlan(plan.epic_id, plan.version - 1, { specsDir });
1281
+ }
1282
+ catch {
1283
+ // No readable previous version alongside this file.
1284
+ }
1285
+ }
1286
+ }
1287
+ const result = validatePlan(plan, previous ? { previous } : {});
1027
1288
  printJson(result);
1028
1289
  return result.valid ? 0 : 1;
1029
1290
  }
@@ -1085,6 +1346,13 @@ async function main() {
1085
1346
  ...(draft ? { plan: draft } : {}),
1086
1347
  ...(plannerConfidence !== undefined ? { plannerConfidence } : {}),
1087
1348
  planOpts: planOptsFromFlags(flags),
1349
+ // Same reasoning as `epic verdict`/`epic close` (D-42/P9-26): an
1350
+ // optional --project hands the quorum a project's
1351
+ // .blacksmith/crosscheck.yml overlay (OpenRouter substitution/extra
1352
+ // judges) rather than only the shipped policy. No worktree
1353
+ // derivation here -- a plan critique has no task worktree to derive
1354
+ // one from -- so an absent --project reads exactly as before.
1355
+ crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir: flags.project }) },
1088
1356
  }, ctx, eventOptsFromFlags(flags));
1089
1357
  printJson(outcome);
1090
1358
  // FD-49: the outcome is a few rationales of several kB each, and the
@@ -1107,7 +1375,12 @@ async function main() {
1107
1375
  const plan = readJsonFile(planFile);
1108
1376
  const ctx = eventContextFromFlags(flags);
1109
1377
  const opts = eventOptsFromFlags(flags);
1110
- const written = await emitTasksAdded(plan, ctx, opts);
1378
+ // --project: an epic already integrating on `smith/<epic>/integration`
1379
+ // (legacy continuity, worktree.ts's epicBranchPrefix) must have this
1380
+ // v(n+1) ingest declare the same prefix `worktree create` will actually
1381
+ // cut, not the plain `bs` default. Absent for a brand-new epic, which has
1382
+ // no such branch to ask git about yet.
1383
+ const written = await emitTasksAdded(plan, ctx, opts, flags.project);
1111
1384
  // D-254: the arrows, after the nodes. The plan declares a DAG and the
1112
1385
  // scheduler has always read it off the file, but nothing wrote it to the
1113
1386
  // log -- so the db's `edges` table was empty and every operator-facing
@@ -1124,6 +1397,11 @@ async function main() {
1124
1397
  // claims-and-edges only, so the input carries no crossings.
1125
1398
  const { computePlanParallelism } = await import('./waveSchedule.js');
1126
1399
  const parallelism = computePlanParallelism(plan, loadWorktreePolicy());
1400
+ // The last event this call appended -- the edges, after the nodes, since
1401
+ // emitEdgesRecorded runs after emitTasksAdded. A re-ingest that wrote
1402
+ // neither (everything already on the log) appends nothing, so there is
1403
+ // no id to print.
1404
+ const lastEvent = edges.length > 0 ? edges[edges.length - 1] : written[written.length - 1];
1127
1405
  printJson({
1128
1406
  epic: plan.epic_id,
1129
1407
  version: plan.version,
@@ -1132,6 +1410,7 @@ async function main() {
1132
1410
  skipped: plan.tasks.length - added,
1133
1411
  edges: edges.length,
1134
1412
  parallelism,
1413
+ ...(lastEvent ? { event_id: lastEvent.event_id } : {}),
1135
1414
  });
1136
1415
  return 0;
1137
1416
  }
@@ -1139,7 +1418,8 @@ async function main() {
1139
1418
  // to cut a version that cites no spec finding, so "the plan changed" is
1140
1419
  // always answerable with "which finding said it was wrong".
1141
1420
  if (namespace === 'plan' && action === 'amend') {
1142
- const plan = readJsonFile(requireFlag(flags, 'plan'));
1421
+ const planPath = requireFlag(flags, 'plan');
1422
+ const plan = readJsonFile(planPath);
1143
1423
  // Comma-split, not repeated: a finding id has no commas, and an amendment
1144
1424
  // routinely cites several at once.
1145
1425
  const findingIds = requireFlag(flags, 'findings')
@@ -1159,7 +1439,7 @@ async function main() {
1159
1439
  rationale: requireFlag(flags, 'rationale'),
1160
1440
  sites,
1161
1441
  ...(changes ? { changes } : {}),
1162
- }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
1442
+ }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...amendSpecsDirOpts(planPath, plan, flags) });
1163
1443
  // No no-op warning here any more. A pure carry-forward used to be legal
1164
1444
  // and merely suspicious — the shape a forgotten --changes takes — so the
1165
1445
  // CLI printed a warning and cut the version anyway. Since D-127 an
@@ -1180,6 +1460,11 @@ async function main() {
1180
1460
  // no task behind it is a deliberate call or a forgotten one.
1181
1461
  sitesUnclaimed: result.sitesUnclaimed,
1182
1462
  diff: result.diff,
1463
+ // #221: an open finding this amendment's own objective text names but
1464
+ // --findings does not cite -- surfaced the same way sitesUnclaimed is,
1465
+ // a fact for the operator to read off the result rather than a guard.
1466
+ warnings: result.warnings,
1467
+ event_id: result.event_id,
1183
1468
  });
1184
1469
  return 0;
1185
1470
  }
@@ -1229,13 +1514,17 @@ async function main() {
1229
1514
  // sites. Approval supplies them from what the worker already recorded.
1230
1515
  if (namespace === 'plan' && action === 'approve') {
1231
1516
  const [proposalId] = requirePositionals(positional, usageFor('plan approve'));
1232
- const plan = readJsonFile(requireFlag(flags, 'plan'));
1517
+ const planPath = requireFlag(flags, 'plan');
1518
+ const plan = readJsonFile(planPath);
1519
+ // #287: approval runs `plan amend` underneath, so it is the same write
1520
+ // `amendSpecsDirOpts` was written for (#219), not the read
1521
+ // `planOptsFromFlags` silently defaults for everyone else.
1233
1522
  const result = await approveSpecChange({
1234
1523
  proposalId,
1235
1524
  plan,
1236
1525
  decidedBy: requireFlag(flags, 'decided-by'),
1237
1526
  ...(flags.rationale ? { rationale: flags.rationale } : {}),
1238
- }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
1527
+ }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...amendSpecsDirOpts(planPath, plan, flags) });
1239
1528
  printJson({
1240
1529
  proposalId,
1241
1530
  epic: result.plan.epic_id,
@@ -1245,6 +1534,7 @@ async function main() {
1245
1534
  sites: result.proposal.sites,
1246
1535
  sitesUnclaimed: result.sitesUnclaimed,
1247
1536
  diff: result.diff,
1537
+ event_id: result.event_id,
1248
1538
  });
1249
1539
  return 0;
1250
1540
  }
@@ -1274,7 +1564,7 @@ async function main() {
1274
1564
  // at. resolveTaskId turns that silence into a named error.
1275
1565
  const typedIds = positional.slice(1);
1276
1566
  if (typedIds.length === 0) {
1277
- throw new SmithError('cli.empty-wave', 'Usage: smith wave check <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
1567
+ throw new SmithError('cli.empty-wave', 'Usage: bs wave check <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
1278
1568
  }
1279
1569
  // D-48/P9-31: a follow-up task exists only in the log, so the log is the
1280
1570
  // second register an id may be found in — and the place its claims come
@@ -1284,6 +1574,8 @@ async function main() {
1284
1574
  ? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
1285
1575
  : [];
1286
1576
  const taskIds = typedIds.map((typed) => resolveTaskId(plan, typed, logged.map((t) => t.taskId)));
1577
+ if (flags.session)
1578
+ await refuseUnadmissibleTasks(plan, taskIds, flags);
1287
1579
  // Neither register is narrowed on the way in. `?? []` used to stand here
1288
1580
  // and on the line above, and it fires on a missing claims field but not on
1289
1581
  // a claims field holding the wrong thing: a plan writing `"claims":
@@ -1351,7 +1643,9 @@ async function main() {
1351
1643
  // the sum of every session that worked on it.
1352
1644
  await readLineageEvents(flags.session, eventOptsFromFlags(flags))
1353
1645
  : [];
1354
- const budgetPolicy = loadBudgetPolicy(flags['budget-policy']);
1646
+ // Sized for the tier this epic runs at: the plan's own `effort` after
1647
+ // effort.yml's security floor, else effort.yml's default_tier.
1648
+ const budgetPolicy = budgetPolicyForPlan(plan, { policyFile: flags['budget-policy'] });
1355
1649
  const budget = checkWaveBudget(budgetEvents, budgetPolicy, declaredWaveBudgets(plan, taskIds, budgetPolicy), { sessionId: flags.session ?? '', epicId: plan.epic_id });
1356
1650
  const overridden = rationale !== '' && blocksAdmission(budget.status);
1357
1651
  const blocked = blocksAdmission(budget.status) && !overridden;
@@ -1365,7 +1659,7 @@ async function main() {
1365
1659
  }
1366
1660
  // Names only, and only when some differ from budgets.yml: which caps came
1367
1661
  // from the box's env rather than the file.
1368
- const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}));
1662
+ const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}, budgetPolicy.tier));
1369
1663
  printJson({
1370
1664
  ...result,
1371
1665
  symbolImpact,
@@ -1563,7 +1857,8 @@ async function main() {
1563
1857
  // the whole safety property: an operator can read what the policy would
1564
1858
  // have let run, and argue with it, before any of it moves. So this appends
1565
1859
  // no event, writes no worktree and starts no agent -- exactly the daemon's
1566
- // invariant, held by the command a person types too.
1860
+ // invariant, held by the command a person types too. No event_id in the
1861
+ // printed JSON either, for the same reason: there is never one to name.
1567
1862
  if (namespace === 'scheduler' && action === 'admit') {
1568
1863
  const { computeProposals, loadSchedulerPolicy } = await import('./scheduler.js');
1569
1864
  const { admitProposals } = await import('./autonomy.js');
@@ -1650,11 +1945,12 @@ async function main() {
1650
1945
  // plan on disk naming no project is this checkout's own epic, so it
1651
1946
  // reads as FACTORY_PROJECT rather than staying an unresolved null the
1652
1947
  // daemon would report as an unlabeled finding and the on/off switch
1653
- // for 'black-smith' would never get asked about. Split into strict
1948
+ // for 'blacksmith' would never get asked about. Split into strict
1654
1949
  // (resolveProjectForTaskRef) and self-only (selfFallbackForTaskRef)
1655
1950
  // tiers so a session's own stamp can ride between them (S2-c).
1656
1951
  resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
1657
1952
  selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
1953
+ planOpts: planOptsFromFlags(flags),
1658
1954
  ...(flags.db ? { dbPath: flags.db } : {}),
1659
1955
  ...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
1660
1956
  };
@@ -1710,7 +2006,7 @@ async function main() {
1710
2006
  // Let acquireLock own the liveness question — one implementation of
1711
2007
  // "is that pid still there" rather than a second opinion here.
1712
2008
  throw new DaemonError('daemon.already-running', `A daemon (pid ${held.pid}, started ${held.startedAt}) already holds ${dir}. ` +
1713
- 'Run `smith daemon stop` first, or `smith daemon status` to see what it last found.', { pid: held.pid, dir });
2009
+ 'Run `bs daemon stop` first, or `bs daemon status` to see what it last found.', { pid: held.pid, dir });
1714
2010
  }
1715
2011
  mkdirSync(dir, { recursive: true });
1716
2012
  // `ignore` would discard the one account of why a detached daemon died.
@@ -1801,11 +2097,32 @@ async function main() {
1801
2097
  const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
1802
2098
  const successors = taskSuccessors(events, epic);
1803
2099
  const from = flags.from;
1804
- const expected = bareTaskId(epic, taskId);
1805
- const actual = successors.get(from) ?? successors.get(`${epic}/${from}`);
1806
- if (actual === undefined || bareTaskId(epic, actual) !== expected) {
2100
+ // Walks the successor chain hop by hop (spec.ts's
2101
+ // `successorChainPath`) rather than asking about the direct successor
2102
+ // alone: a plan amended twice before the intermediate successor is
2103
+ // ever cut still names `taskId` as `from`'s transitive successor, and
2104
+ // the log is the register this guard is answerable to.
2105
+ const chain = successorChainPath(epic, from, taskId, successors);
2106
+ if (chain === undefined) {
1807
2107
  throw new SmithError('worktree.not-a-successor', `${epic}/${taskId} is not logged as the successor of ${epic}/${from}; run \`plan propose\`/\`plan approve\` with a supersede pairing them first.`, { epic, taskId, from });
1808
2108
  }
2109
+ // The log naming a chain is not enough: `createTaskWorktree` branches
2110
+ // `taskId` directly off `from`'s own branch, so if some hop strictly
2111
+ // between them was already cut as its own branch, skipping straight
2112
+ // there would silently drop that hop's commits. `chain` is `from`'s
2113
+ // own hops through `taskId` inclusive (spec.ts's `successorChainPath`
2114
+ // doc comment); drop the last one (`taskId` itself, which never has a
2115
+ // branch yet) and refuse on the intermediate hop closest to `taskId`
2116
+ // if any of them do.
2117
+ const intermediates = chain.slice(0, -1);
2118
+ let cutIntermediate;
2119
+ for (const hop of intermediates) {
2120
+ if (taskBranchExists(projectDir, epic, hop))
2121
+ cutIntermediate = hop;
2122
+ }
2123
+ if (cutIntermediate !== undefined) {
2124
+ throw new SmithError('worktree.chain-intermediate-cut', `${epic}/${taskId} cannot be cut --from ${epic}/${from}: ${epic}/${cutIntermediate} sits between them and already has its own branch (${taskBranchName(epic, cutIntermediate, epicBranchPrefix(projectDir, epic))}), so its commits would be silently dropped; rerun with --from ${cutIntermediate} instead.`, { epic, taskId, from, intermediate: cutIntermediate });
2125
+ }
1809
2126
  printJson(createTaskWorktree(projectDir, epic, taskId, { from }));
1810
2127
  return 0;
1811
2128
  }
@@ -1885,6 +2202,110 @@ async function main() {
1885
2202
  printJson({ event_id: stored.event_id, record: stored.record });
1886
2203
  return 0;
1887
2204
  }
2205
+ // The hook-driven sibling of `prompt record`: reads one UserPromptSubmit
2206
+ // payload on stdin and files it where promptCapture.ts decides. The plugin
2207
+ // reaches the same function through the lean `bs-prompt-hook` bin; this verb
2208
+ // is the surface the CLI tests drive. It never fails a prompt, so every
2209
+ // outcome is exit 0.
2210
+ if (namespace === 'prompt' && action === 'capture') {
2211
+ try {
2212
+ const line = await capturePrompt(readFileSync(0, 'utf8'), captureContext(process.cwd(), process.env));
2213
+ if (line !== null)
2214
+ console.log(line);
2215
+ }
2216
+ catch {
2217
+ // A capture that fails loses one record; the prompt itself goes through.
2218
+ }
2219
+ return 0;
2220
+ }
2221
+ // Backfill: replays a Claude Code transcript into the home prompt log the
2222
+ // hook would have written (promptImport.ts). Unlike `capture` it is an
2223
+ // explicit act, so a refusal is exit 1. --dry-run prints each event first.
2224
+ if (namespace === 'prompt' && action === 'import') {
2225
+ const { events, summary } = await importTranscript(requireFlag(flags, 'transcript'), captureContext(process.cwd(), process.env), { dryRun: flags['dry-run'] === 'true' });
2226
+ if (flags['dry-run'] === 'true')
2227
+ for (const event of events)
2228
+ console.log(JSON.stringify(event));
2229
+ console.log(JSON.stringify(summary));
2230
+ return 0;
2231
+ }
2232
+ // The operator's mid-run comment on a task, from the dashboard or from
2233
+ // GitHub, and its two readers/writers below (feedback.ts). This PR is the
2234
+ // store and the CLI only; the dashboard form and the playbook that acts on
2235
+ // `smith feedback pending`'s suggestion are later work.
2236
+ if (namespace === 'feedback' && action === 'record') {
2237
+ const taskId = requireFlag(flags, 'task');
2238
+ const bodyFile = flags['body-file'];
2239
+ const body = bodyFile !== undefined ? readFileSync(bodyFile, 'utf8') : flags.body;
2240
+ if (body === undefined) {
2241
+ throw new SmithError('cli.missing-flag', 'Missing --body or --body-file: operator feedback needs a body.', { flag: 'body' });
2242
+ }
2243
+ if (flags.kind !== undefined && flags.kind !== 'must-fix' && flags.kind !== 'nice-to-have') {
2244
+ throw new SmithError('cli.invalid-flag', `--kind must be "must-fix" or "nice-to-have", got "${flags.kind}".`, { flag: 'kind', value: flags.kind });
2245
+ }
2246
+ if (flags.source !== undefined &&
2247
+ flags.source !== 'dashboard' &&
2248
+ flags.source !== 'github' &&
2249
+ flags.source !== 'cli') {
2250
+ throw new SmithError('cli.invalid-flag', `--source must be "dashboard", "github" or "cli", got "${flags.source}".`, { flag: 'source', value: flags.source });
2251
+ }
2252
+ const result = await recordFeedback({
2253
+ taskId,
2254
+ body,
2255
+ kind: flags.kind,
2256
+ source: flags.source,
2257
+ externalId: flags['external-id'],
2258
+ author: flags.author,
2259
+ }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
2260
+ printJson({
2261
+ event_id: result.event.event_id,
2262
+ feedback_id: result.feedbackId,
2263
+ deduped: result.deduped,
2264
+ });
2265
+ return 0;
2266
+ }
2267
+ if (namespace === 'feedback' && action === 'pending') {
2268
+ const sessionId = requireFlag(flags, 'session');
2269
+ const eventOpts = eventOptsFromFlags(flags);
2270
+ requireSession(sessionId, eventOpts);
2271
+ const taskId = flags.task;
2272
+ const epic = flags.epic;
2273
+ const pending = await listPendingFeedback(sessionId, { taskId }, eventOpts);
2274
+ const scoped = epic === undefined ? pending : pending.filter((row) => epicOfTaskId(row.taskId) === epic);
2275
+ // Kept out of the boot graph like every other db/projector.js call site
2276
+ // (P9-2, test/cliBoot.test.ts): the module pulls in drizzle-orm, and
2277
+ // `smith --help` should not pay for a database it never opens.
2278
+ const { foldTasks } = await import('./db/projector.js');
2279
+ const statuses = new Map(foldTasks(await readLineageEvents(sessionId, eventOpts)).map((t) => [t.taskId, t.taskStatus]));
2280
+ const withAction = scoped.map((row) => ({
2281
+ ...row,
2282
+ // A task not yet closed to further work gets its comment bounced back
2283
+ // to the coder still on it; one that already merged (or was superseded
2284
+ // or waived) gets a follow-up task instead (taskStatus.ts's
2285
+ // CLOSED_TO_FURTHER_WORK, the same question claims.ts's
2286
+ // decideFindingAttribution answers for a finding).
2287
+ suggestedAction: CLOSED_TO_FURTHER_WORK.has(statuses.get(row.taskId) ?? '')
2288
+ ? 'follow-up'
2289
+ : 'bounce',
2290
+ }));
2291
+ printJson({ sessionId, pending: withAction, count: withAction.length });
2292
+ return 0;
2293
+ }
2294
+ if (namespace === 'feedback' && action === 'resolve') {
2295
+ const feedbackId = requireFlag(flags, 'feedback');
2296
+ const resolution = requireFlag(flags, 'resolution');
2297
+ if (resolution !== 'bounced' && resolution !== 'follow-up' && resolution !== 'dismissed') {
2298
+ throw new SmithError('cli.invalid-flag', `--resolution must be "bounced", "follow-up" or "dismissed", got "${resolution}".`, { flag: 'resolution', value: resolution });
2299
+ }
2300
+ const stored = await resolveFeedback({
2301
+ feedbackId,
2302
+ resolution: resolution,
2303
+ note: flags.note,
2304
+ followUpTaskId: flags['follow-up-task'],
2305
+ }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
2306
+ printJson({ event_id: stored.event_id, record: stored.record });
2307
+ return 0;
2308
+ }
1888
2309
  if (namespace === 'research' && action === 'check') {
1889
2310
  const result = checkBrief(readJsonFile(requireFlag(flags, 'brief')));
1890
2311
  printJson(result);
@@ -1909,20 +2330,23 @@ async function main() {
1909
2330
  if (selectTestCmd !== undefined)
1910
2331
  assertSelectableTestCmd(selectTestCmd);
1911
2332
  const tasks = readJsonFile(tasksFile);
1912
- // D-46/P9-29: the queue is the only component that knows a branch landed,
1913
- // and it used to say so to stdout and nowhere else — which is why the
1914
- // projector's `completed` column was unreachable by machine. `--session`
1915
- // is what turns the run into a fact; without it the queue still runs, and
1916
- // still tells nobody.
1917
- const events = flags.session
1918
- ? { ctx: eventContextFromFlags(flags), ...eventOptsFromFlags(flags) }
1919
- : undefined;
2333
+ // D-46/P9-29/#269: the queue is the only component that knows a branch
2334
+ // landed, and it used to say so to stdout and nowhere else — which is why
2335
+ // the projector's `completed` column was unreachable by machine, and why
2336
+ // a wave that followed the playbook literally still left `wave-merged`
2337
+ // unwritten. `--session` is what turns the run into a fact, so a run
2338
+ // with no session to write it into is refused before any git write
2339
+ // below, rather than left to merge and tell nobody.
2340
+ if (!flags.session) {
2341
+ throw new SmithError('cli.queue-run-needs-session', 'queue run merges task branches into the integration branch, so every merge must be logged: pass --session <id> --causal-parent <event-id> --plan <plan.json> (add --plan-version if the plan is not version 1).', { epic });
2342
+ }
2343
+ const events = { ctx: eventContextFromFlags(flags), ...eventOptsFromFlags(flags) };
1920
2344
  // `--tasks` is hand-written, so its ids are whatever was typed. Minting
1921
2345
  // them from the plan before any git runs is the whole point of P9-29: a
1922
2346
  // bare id here would put `wave-merged` in the log under a spelling the
1923
2347
  // plan never used, which is how the dogfood epic folded one task instead
1924
2348
  // of six. Refuse the run whole rather than merge some and mislabel them.
1925
- if (events && !flags.plan) {
2349
+ if (!flags.plan) {
1926
2350
  throw new SmithError('cli.missing-flag', 'queue run --session also needs --plan <plan.json>: a merge may only be logged under the task id the plan declares.', { epic });
1927
2351
  }
1928
2352
  // --batch (roadmap merge-lanes) groups tasks with groupForBatch before
@@ -1930,12 +2354,10 @@ async function main() {
1930
2354
  // does not carry: the plan's `edges`, so a task never stacks with
1931
2355
  // something it depends_on, and — for a tasks-file entry that omits its
1932
2356
  // own `claims` — the plan's claim list, so a hand-typed batch run is not
1933
- // blind to a disjointness `wave admit` already knows. Same shape as the
1934
- // `--session` guard just above, and for the same reason: refuse the run
1935
- // whole rather than group some tasks correctly and others by guesswork.
1936
- if (flags.batch === 'true' && !flags.plan) {
1937
- throw new SmithError('cli.missing-flag', 'queue run --batch also needs --plan <plan.json>: grouping reads its dependency edges and claim lists from there.', { epic });
1938
- }
2357
+ // blind to a disjointness `wave admit` already knows. `--plan` is
2358
+ // unconditionally required above since #269, so a batch run with none is
2359
+ // already refused there.
2360
+ //
1939
2361
  // `attemptCandidate` runs the epic's test command directly against the
1940
2362
  // whole batch candidate — there is no per-task file set to narrow it to,
1941
2363
  // so a `--select-test-cmd` template would either render nonsensically or
@@ -2073,7 +2495,10 @@ async function main() {
2073
2495
  // the merge — and adopt any task with it, which is the forgery the whole
2074
2496
  // verb exists to prevent. Deriving it from the plan-resolved id means the
2075
2497
  // branch and the id it is logged under cannot disagree.
2076
- { taskId, branch: taskBranchName(plan.epic_id, taskId) }, {
2498
+ {
2499
+ taskId,
2500
+ branch: taskBranchName(plan.epic_id, taskId, epicBranchPrefix(projectDir, plan.epic_id)),
2501
+ }, {
2077
2502
  projectDir,
2078
2503
  epic: plan.epic_id,
2079
2504
  mergeCommit,
@@ -2091,6 +2516,7 @@ async function main() {
2091
2516
  ...eventOptsFromFlags(flags),
2092
2517
  ...(flags.actor === undefined ? {} : { actor: flags.actor }),
2093
2518
  ...(flags.continues === undefined ? {} : { continues: flags.continues }),
2519
+ ...(flags.prompt === undefined ? {} : { promptId: flags.prompt }),
2094
2520
  }));
2095
2521
  return 0;
2096
2522
  }
@@ -2154,7 +2580,7 @@ async function main() {
2154
2580
  process.stderr.write(`warning: this session-start is ${result.event_id}, not ${result.record.session_id}#0 — ` +
2155
2581
  `session "${result.record.session_id}" was already open. The record is written and ` +
2156
2582
  'durable, but a log has one root and readers take the first, so nothing will read this ' +
2157
- 'one. Use `smith session start`, which refuses instead of receipting, and chain off the ' +
2583
+ 'one. Use `bs session start`, which refuses instead of receipting, and chain off the ' +
2158
2584
  'event that is already there.\n');
2159
2585
  }
2160
2586
  printJson({ ...result, on_timeline: onTimeline });
@@ -2394,7 +2820,7 @@ async function main() {
2394
2820
  // Same refusal as `wave check`, for the same reason: validating the
2395
2821
  // empty set answers "admissible" about a wave nobody described.
2396
2822
  if (positional.length === 0) {
2397
- throw new SmithError('cli.empty-wave', 'Usage: smith claims impact --plan <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
2823
+ throw new SmithError('cli.empty-wave', 'Usage: bs claims impact --plan <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
2398
2824
  }
2399
2825
  const recordsById = new Map(plan.tasks.map((task) => [task.task_id, task]));
2400
2826
  // Claims and keeps_exports are read through the one door validateWave
@@ -2562,7 +2988,7 @@ async function main() {
2562
2988
  worktree: flags.worktree ?? null,
2563
2989
  ...(tier !== undefined ? { tier } : {}),
2564
2990
  ...(flags.schema !== undefined ? { schema: flags.schema } : {}),
2565
- }, { policy: loadHarnessPolicy(flags.policy) });
2991
+ }, { policy: loadHarnessPolicy(flags.policy), planOpts: planOptsFromFlags(flags) });
2566
2992
  printJson(invocation);
2567
2993
  return 0;
2568
2994
  }
@@ -2648,6 +3074,14 @@ async function main() {
2648
3074
  const { runGate } = await import('./gate.js');
2649
3075
  const [taskId] = requirePositionals(positional, usageFor('gate run'));
2650
3076
  const worktreeDir = requireFlag(flags, 'worktree');
3077
+ // --project overrides; otherwise derived from --worktree, whose main
3078
+ // checkout IS the project (a task worktree is a git-worktree sibling,
3079
+ // `.wt/<project>/<task>`, not a child -- see projectDirFromFlags). Either
3080
+ // way this reaches the gate's own crosscheck load below, so a project's
3081
+ // .blacksmith/crosscheck.yml overlay (OpenRouter substitution/extra
3082
+ // judges) governs the quorum a gate run can trigger, not just the
3083
+ // shipped policy.
3084
+ const projectDir = projectDirFromFlags(flags);
2651
3085
  const checks = readChecksFile(requireFlag(flags, 'checks'));
2652
3086
  // The result file has the same two intake shapes as findings below, and for
2653
3087
  // the same reason. With `--agent`, `--result` is the worker's half —
@@ -2656,6 +3090,13 @@ async function main() {
2656
3090
  // meter, so a token count it writes is invented (D-18/P9-17). Without
2657
3091
  // `--agent` the file is taken as a complete document, which is what a
2658
3092
  // replay or a fixture hands over.
3093
+ //
3094
+ // `--input-tokens`/`--output-tokens` are optional, both-or-neither (#220):
3095
+ // a harness that ran the subprocess itself has real numbers, but a
3096
+ // dispatcher with no API onto a subagent's token spend — one running
3097
+ // inside Claude Code, say — has neither, and stampResultEnvelope turns
3098
+ // that absence into an honest `{measured: false}` rather than have the
3099
+ // caller invent a count to satisfy a mandatory flag.
2659
3100
  const resultFile = readJsonFile(requireFlag(flags, 'result'));
2660
3101
  const result = flags.agent
2661
3102
  ? stampResultEnvelope(resultFile, {
@@ -2663,8 +3104,8 @@ async function main() {
2663
3104
  agent: flags.agent,
2664
3105
  provider: requireFlag(flags, 'provider'),
2665
3106
  modelTier: requireFlag(flags, 'model-tier'),
2666
- inputTokens: requireIntFlag(flags, 'input-tokens'),
2667
- outputTokens: requireIntFlag(flags, 'output-tokens'),
3107
+ inputTokens: boundedIntFlag(flags, 'input-tokens', { min: 0 }),
3108
+ outputTokens: boundedIntFlag(flags, 'output-tokens', { min: 0 }),
2668
3109
  })
2669
3110
  : resultFile;
2670
3111
  // Two intake shapes. `--evidence` is what judges actually produce
@@ -2673,8 +3114,9 @@ async function main() {
2673
3114
  // judge at this gate (D-32/P9-13). `--findings` stays for already-minted
2674
3115
  // records (replays, fixtures, cross-check re-runs).
2675
3116
  const findingsInput = [
2676
- ...(flags.findings ? readJsonFile(flags.findings) : []),
3117
+ ...(flags.findings ? readFindingsInputFile(flags.findings) : []),
2677
3118
  ...mintFromEvidence(args, taskId),
3119
+ ...(flags['uiux-visual'] ? mintFromUiuxVisual(flags['uiux-visual'], taskId) : []),
2678
3120
  ];
2679
3121
  const lessons = flags.lessons ? parseLessons(readFileSync(flags.lessons, 'utf8')) : [];
2680
3122
  const ctx = eventContextFromFlags(flags);
@@ -2709,18 +3151,24 @@ async function main() {
2709
3151
  // the shape the grader's turn stayed open with its verdict on the command
2710
3152
  // line, and the gate blocked on the judge it was about to read.
2711
3153
  const evidenceGiven = evidenceSources(args);
2712
- const graderGiven = flags.grader ? [{ foundBy: 'grader', file: flags.grader }] : [];
2713
- const handedIn = [...evidenceGiven, ...graderGiven];
3154
+ const graderGiven = flags.grader
3155
+ ? [{ foundBy: 'grader', file: flags.grader }]
3156
+ : [];
3157
+ const uiuxVisualGiven = flags['uiux-visual']
3158
+ ? [{ foundBy: 'uiux', file: flags['uiux-visual'], kind: 'visual' }]
3159
+ : [];
3160
+ const handedIn = [...evidenceGiven, ...graderGiven, ...uiuxVisualGiven];
2714
3161
  if (handedIn.length > 0) {
2715
3162
  const turns = await readJudgeTurns(taskId, ctx, eventOptsFromFlags(flags));
2716
3163
  const closed = new Set();
2717
- for (const { foundBy, file } of handedIn) {
2718
- if (closed.has(foundBy))
3164
+ for (const { foundBy, file, kind } of handedIn) {
3165
+ const closeKey = kind ? `${foundBy}:${kind}` : foundBy;
3166
+ if (closed.has(closeKey))
2719
3167
  continue;
2720
- if (!turns.some((t) => t.role === foundBy && !t.reported))
3168
+ if (!turns.some((t) => t.role === foundBy && t.kind === (kind ?? null) && !t.reported))
2721
3169
  continue;
2722
- closed.add(foundBy);
2723
- await recordJudgeReport({ taskId, role: foundBy, artifactPath: file }, ctx, eventOptsFromFlags(flags));
3170
+ closed.add(closeKey);
3171
+ await recordJudgeReport({ taskId, role: foundBy, artifactPath: file, ...(kind ? { kind } : {}) }, ctx, eventOptsFromFlags(flags));
2724
3172
  }
2725
3173
  }
2726
3174
  // P9-11: the genuinely clean case, said out loud. A judge that found
@@ -2733,7 +3181,7 @@ async function main() {
2733
3181
  // role called "true" would close nothing (no such dispatch exists) and
2734
3182
  // say so nowhere, so a bare --no-findings is a usage error instead.
2735
3183
  if (role === 'true') {
2736
- throw new SmithError('cli.no-findings-needs-role', '--no-findings names the judge role it attests for, e.g. --no-findings security-reviewer.', { usage: 'smith gate run <task-id> --no-findings <role>' });
3184
+ throw new SmithError('cli.no-findings-needs-role', '--no-findings names the judge role it attests for, e.g. --no-findings security-reviewer.', { usage: 'bs gate run <task-id> --no-findings <role>' });
2737
3185
  }
2738
3186
  await recordJudgeReport({ taskId, role, noFindings: true }, ctx, eventOptsFromFlags(flags));
2739
3187
  }
@@ -2762,10 +3210,75 @@ async function main() {
2762
3210
  ...(graderVerdict !== undefined ? { graderVerdict } : {}),
2763
3211
  ...(budget ? { budget } : {}),
2764
3212
  ...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
3213
+ uiAffecting: uiAffectingFromFlags(flags, taskId),
3214
+ crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir }) },
3215
+ }, ctx, eventOptsFromFlags(flags));
3216
+ printJson(outcome);
3217
+ return outcome.outcome === 'blocked' ? 1 : 0;
3218
+ }
3219
+ // The tester's screenshots reach the dashboard the moment the tester
3220
+ // finishes, not only at the next `gate run`. wave.md's tester has no
3221
+ // worktree to certify and no tests to run, so it was never handed to
3222
+ // `gate run` at all — and its declared artifacts never reached the
3223
+ // projector (`${event_id}#${index}` rows in db/projector.ts). This is the
3224
+ // schema-check/task-result-recorded/artifact-check third of `gate run`
3225
+ // exposed on its own, over the same `recordTaskResult` (gate.ts) rather
3226
+ // than a second copy of either check, and it never emits `gate-outcome`:
3227
+ // epic.ts's ungated-task check (D-138) treats that event as proof a full
3228
+ // gate ran, and a lean Result projection is not one.
3229
+ if (namespace === 'results' && action === 'record') {
3230
+ const { recordTaskResult } = await import('./gate.js');
3231
+ const taskId = requireFlag(flags, 'task');
3232
+ // The same two intake shapes as `gate run`'s `--result`, for the same
3233
+ // reason (D-18/P9-17): with `--agent` the file is the worker's half and
3234
+ // the dispatcher stamps token_usage, so a tester's own count never reaches
3235
+ // the budget reads.
3236
+ const resultFile = readJsonFile(requireFlag(flags, 'result'));
3237
+ const result = flags.agent
3238
+ ? stampResultEnvelope(resultFile, {
3239
+ taskId,
3240
+ agent: flags.agent,
3241
+ provider: requireFlag(flags, 'provider'),
3242
+ modelTier: requireFlag(flags, 'model-tier'),
3243
+ inputTokens: boundedIntFlag(flags, 'input-tokens', { min: 0 }),
3244
+ outputTokens: boundedIntFlag(flags, 'output-tokens', { min: 0 }),
3245
+ })
3246
+ : resultFile;
3247
+ const ctx = eventContextFromFlags(flags);
3248
+ const outcome = await recordTaskResult({
3249
+ taskId,
3250
+ result,
3251
+ ...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
3252
+ ...(flags.worktree ? { worktreeDir: flags.worktree } : {}),
2765
3253
  }, ctx, eventOptsFromFlags(flags));
2766
3254
  printJson(outcome);
2767
3255
  return outcome.outcome === 'blocked' ? 1 : 0;
2768
3256
  }
3257
+ // U2 S3/D4: the preflight for the gate's uiux stage, over the same
3258
+ // checkUiux the stage itself calls — so a dispatcher can ask "is this task's
3259
+ // screenshot set fresh and reviewed yet" without staging a whole gate run,
3260
+ // and the two can never disagree about the same fixture.
3261
+ if (namespace === 'uiux' && action === 'check') {
3262
+ const { checkUiux } = await import('./uiuxGate.js');
3263
+ const taskId = requireFlag(flags, 'task');
3264
+ const plan = readJsonFile(requireFlag(flags, 'plan'));
3265
+ const resolved = resolveTaskId(plan, taskId);
3266
+ const spec = liveSpec(plan, resolved);
3267
+ const ctx = eventContextFromFlags(flags);
3268
+ const opts = eventOptsFromFlags(flags);
3269
+ if (!spec || !isUiAffecting(spec)) {
3270
+ printJson({ outcome: 'pass' });
3271
+ return 0;
3272
+ }
3273
+ const head = flags.worktree ? runGit(flags.worktree, ['rev-parse', 'HEAD']) : undefined;
3274
+ const result = await checkUiux({
3275
+ taskId: resolved,
3276
+ ...(head !== undefined ? { head } : {}),
3277
+ ...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
3278
+ }, ctx, opts);
3279
+ printJson(result);
3280
+ return result.outcome === 'blocked' ? 1 : 0;
3281
+ }
2769
3282
  // D-40/P9-25: the gate's coverage evidence, without staging a gate run.
2770
3283
  // This is the verb the D-40 investigation wanted and did not have — it took
2771
3284
  // a coverage re-run on the pre-task-4 integration branch to establish what
@@ -2812,11 +3325,21 @@ async function main() {
2812
3325
  // session reports half an epic's cost as the whole of it, and reports it
2813
3326
  // under an alarm threshold it may already have crossed.
2814
3327
  const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
3328
+ // Each epic is judged against budgets.yml sized for its own effort tier
3329
+ // (its latest plan's `effort`); the report's top-level cap is the default
3330
+ // tier's, and each epic line carries the cap it was judged against.
3331
+ const policyForEpic = epicBudgetPolicies({
3332
+ policyFile: flags.policy,
3333
+ planOpts: planOptsFromFlags(flags),
3334
+ });
2815
3335
  const report = checkBudgetAlarm(events, loadBudgetPolicy(flags.policy), {
2816
3336
  sessionId,
2817
3337
  ...(flags.epic ? { epicId: flags.epic } : {}),
3338
+ policyForEpic,
2818
3339
  });
2819
- const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags.policy, {}));
3340
+ const envOverrides = [
3341
+ ...new Set([undefined, ...report.epics.filter((e) => e.epicId !== '*').map((e) => e.epicId)].flatMap((epicId) => budgetEnvOverrides(loadBudgetPolicy(flags.policy, {}, epicId === undefined ? undefined : policyForEpic(epicId).tier)))),
3342
+ ];
2820
3343
  printJson(envOverrides.length > 0 ? { ...report, budgetEnvOverrides: envOverrides } : report);
2821
3344
  return report.ok ? 0 : 1;
2822
3345
  }
@@ -2869,12 +3392,26 @@ async function main() {
2869
3392
  const outcome = await runEpicVerdict({
2870
3393
  epicId,
2871
3394
  integrationHeadSha: integrationHeadSha(projectDir, epicId),
3395
+ integrationBranch: integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)),
2872
3396
  mcp: mcpSurfaceFor(epicId, projectDir, flags),
2873
3397
  goal: epicGoalFor(epicId, flags),
2874
3398
  // D-126: the live plan is a voter. Without this the roster is the
2875
3399
  // event-log fold alone, and a task an amendment added but nobody
2876
3400
  // dispatched is invisible rather than unfinished.
2877
3401
  planOpts: planOptsFromFlags(flags),
3402
+ // run.md step 13: tier `small` may waive an absent closing spec
3403
+ // review, but only `summarizeEpic` can tell an epic's tier apart
3404
+ // from a hole nobody filled -- resolved the same way `effort show`
3405
+ // resolves it, security floor included.
3406
+ effort: resolveEpicEffort(epicId, flags),
3407
+ // --project is already required above; hand it to the crosscheck
3408
+ // load too, so a project's .blacksmith/crosscheck.yml overlay
3409
+ // (OpenRouter substitution/extra judges) is what the verdict's
3410
+ // quorum actually reads, not just what preflight reports. No
3411
+ // --policy override for this command (unlike judge preflight):
3412
+ // the shipped/default path plus the project overlay is the only
3413
+ // combination epic verdict has ever read.
3414
+ crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir }) },
2878
3415
  }, ctx, eventOptsFromFlags(flags));
2879
3416
  printJson(outcome);
2880
3417
  return outcome.outcome === 'hold' ? 1 : 0;
@@ -2891,9 +3428,17 @@ async function main() {
2891
3428
  const record = await closeEpic({
2892
3429
  epicId,
2893
3430
  integrationHeadSha: integrationHeadSha(projectDir, epicId),
3431
+ integrationBranch: integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)),
2894
3432
  mcp: mcpSurfaceFor(epicId, projectDir, flags),
2895
3433
  goal: epicGoalFor(epicId, flags),
2896
3434
  planOpts: planOptsFromFlags(flags),
3435
+ // See `epic verdict` above: same tier resolution, so a close can
3436
+ // never waive a review its own verdict would have blocked on.
3437
+ effort: resolveEpicEffort(epicId, flags),
3438
+ // Same reasoning as `epic verdict` above: the project overlay must
3439
+ // reach the close's own crosscheck load too, not just the verdict
3440
+ // it is closing over.
3441
+ crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir }) },
2897
3442
  ...(flags['override-rationale'] !== undefined
2898
3443
  ? { overrideRationale: flags['override-rationale'] }
2899
3444
  : {}),
@@ -2912,13 +3457,26 @@ async function main() {
2912
3457
  const plan = readJsonFile(requireFlag(flags, 'plan'));
2913
3458
  // Evidence, not identity (interview N-2): the reviewer reports what it
2914
3459
  // read; the plan version and the head it was read at come from here.
3460
+ //
3461
+ // Exactly one of --evidence or --no-findings (issue #249). A missing
3462
+ // --evidence used to read as "found nothing", so a reviewer that never
3463
+ // wrote its file closed the review clean. Same rule as `judge report`:
3464
+ // an empty review is the file saying so, or the operator attesting it.
3465
+ const noFindings = flags['no-findings'] === 'true';
3466
+ if (noFindings && flags.evidence) {
3467
+ throw new SmithError('cli.incompatible-flags', 'epic spec-review takes --evidence <file> or --no-findings, not both: the review either handed over its findings file or the operator attests it found nothing.', { epicId });
3468
+ }
3469
+ if (!noFindings && !flags.evidence) {
3470
+ throw new SmithError('cli.missing-flag', "epic spec-review needs --evidence <file> (the reviewer's findings list, `[]` when it found nothing) or --no-findings (the operator attests an empty review). A missing file is not an empty review.", { epicId, flag: 'evidence' });
3471
+ }
2915
3472
  const evidence = flags.evidence ? readJsonFile(flags.evidence) : [];
2916
3473
  // No branch, no review. The whole point of this dispatch is that it reads
2917
3474
  // the code that now exists; recording one against a head that could not be
2918
3475
  // read would produce a review nothing can be shown to cover.
2919
3476
  const headSha = integrationHeadSha(projectDir, epicId);
2920
3477
  if (headSha === null) {
2921
- throw new SmithError('cli.no-integration-branch', `Could not read the head of smith/${epicId}/integration in ${projectDir}. The closing spec review reads the assembled branch, so there is nothing to review yet.`, { epicId, projectDir });
3478
+ const branch = integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId));
3479
+ throw new SmithError('cli.no-integration-branch', `Could not read the head of ${branch} in ${projectDir}. The closing spec review reads the assembled branch, so there is nothing to review yet.`, { epicId, projectDir });
2922
3480
  }
2923
3481
  const record = await recordSpecReview({
2924
3482
  epicId,
@@ -2929,6 +3487,7 @@ async function main() {
2929
3487
  ? { reviewedByProvider: flags['reviewed-by-provider'] }
2930
3488
  : {}),
2931
3489
  evidence,
3490
+ ...(noFindings ? { noFindings: true } : {}),
2932
3491
  }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
2933
3492
  printJson(record);
2934
3493
  // Exit 0 even when it found something: the review ran, and a spec finding
@@ -3046,7 +3605,7 @@ async function main() {
3046
3605
  // are indistinguishable to everything downstream.
3047
3606
  const mintTaskId = specDispatch && plan ? `${plan.epic_id}/${RESERVED_TASK_ID}` : defaultTaskId;
3048
3607
  const findingsInput = [
3049
- ...(flags.findings ? readJsonFile(flags.findings) : []),
3608
+ ...(flags.findings ? readFindingsInputFile(flags.findings) : []),
3050
3609
  // The mint id, not the default one: a spec finding belongs to the epic
3051
3610
  // (D-33), and the dispatch's scope rides along with it so every finding
3052
3611
  // in the batch is spec-scoped by construction.
@@ -3084,7 +3643,9 @@ async function main() {
3084
3643
  // can hold the fix, because the fix is a plan amendment.
3085
3644
  const isSpec = !result.suppressed && findingScope(result.finding) === SPEC_FINDING_SCOPE;
3086
3645
  if (!result.suppressed && !isSpec) {
3087
- await recordReattribution(routing, result.finding, ctx, opts);
3646
+ // The plan, when given, says which project a follow-up belongs to --
3647
+ // the same fallback the plan-driven task writers take (planScoped).
3648
+ await recordReattribution(routing, result.finding, plan?.project ? { ...ctx, project: plan.project } : ctx, opts);
3088
3649
  }
3089
3650
  raised.push({
3090
3651
  findingId: result.suppressed ? null : result.finding.finding_id,
@@ -3093,6 +3654,8 @@ async function main() {
3093
3654
  attribution: isSpec ? SPEC_FINDING_SCOPE : routing.attribution,
3094
3655
  suppressed: result.suppressed,
3095
3656
  reason: routing.reason,
3657
+ // A suppressed finding appended nothing, so it carries no event_id.
3658
+ event_id: result.suppressed ? undefined : result.event_id,
3096
3659
  });
3097
3660
  }
3098
3661
  printJson(raised);
@@ -3132,11 +3695,11 @@ async function main() {
3132
3695
  // Refusing here, on the status alone, keeps argument order as the reading
3133
3696
  // order and lets the error name the verb that CAN take the edge.
3134
3697
  const AMENDMENT_ROUTES = {
3135
- [AMEND_PENDING_STATUS]: '`smith plan amend` puts every finding it cites into "amend-pending", with the task ids the new plan version added or superseded as the obligation',
3136
- [AMENDED_STATUS]: '`smith epic close` computes which of those tasks actually landed and discharges the finding with that evidence',
3698
+ [AMEND_PENDING_STATUS]: '`bs plan amend` puts every finding it cites into "amend-pending", with the task ids the new plan version added or superseded as the obligation',
3699
+ [AMENDED_STATUS]: '`bs epic close` computes which of those tasks actually landed and discharges the finding with that evidence',
3137
3700
  };
3138
3701
  if (newStatus in AMENDMENT_ROUTES) {
3139
- throw new SmithError('cli.amendment-edge-unreachable', `"${newStatus}" is not reachable through "smith findings transition": the amendment path is entered and closed by the commands that can compute its evidence, never by hand. ${AMENDMENT_ROUTES[newStatus]}.`, { status: newStatus, findingId });
3702
+ throw new SmithError('cli.amendment-edge-unreachable', `"${newStatus}" is not reachable through "bs findings transition": the amendment path is entered and closed by the commands that can compute its evidence, never by hand. ${AMENDMENT_ROUTES[newStatus]}.`, { status: newStatus, findingId });
3140
3703
  }
3141
3704
  const ctx = eventContextFromFlags(flags);
3142
3705
  const finding = await transitionFinding(findingId, newStatus, ctx, eventOptsFromFlags(flags));
@@ -3145,20 +3708,25 @@ async function main() {
3145
3708
  }
3146
3709
  // The dispatch-time half of P9-15, shaped exactly like `lessons
3147
3710
  // for-dispatch`: the caller composing a task prompt asks for the block and
3148
- // splices it. `--plan` is required rather than optional here — without it
3149
- // the claims list is empty, every finding fails the join, and the command
3150
- // would answer "nothing is open in your files" when it never looked.
3711
+ // splices it. `--plan` and `--task` come as a pair: a `--task` without
3712
+ // `--plan` would have an empty claims list, every finding would fail the
3713
+ // join, and the command would answer "nothing is open in your files" when it
3714
+ // never looked. Neither is the epic-level shape (#248) — a role with no task
3715
+ // of its own (dispatch.md) gets every open finding in the epic instead.
3151
3716
  if (namespace === 'findings' && action === 'for-dispatch') {
3152
- const usage = 'smith findings for-dispatch --session ... --plan plan.json --task task-id [--state-dir dir]';
3153
- requireFlag(flags, 'plan');
3154
- const taskId = requireFlag(flags, 'task');
3717
+ const usage = usageLine(usageFor('findings for-dispatch'));
3718
+ if (flags.task !== undefined)
3719
+ requireFlag(flags, 'plan');
3155
3720
  const sessionId = requireFlag(flags, 'session');
3156
3721
  const eventOpts = eventOptsFromFlags(flags);
3157
3722
  requireSession(sessionId, eventOpts);
3158
3723
  if (positional.length > 0) {
3159
3724
  throw new SmithError('cli.usage', `Unexpected argument. Usage: ${usage}`, { positional });
3160
3725
  }
3161
- printJson(await findingsForDispatch({ sessionId, taskId, claims: claimsForDispatch(flags) }, eventOpts));
3726
+ // claimsForDispatch refuses a `--plan` with no `--task`, so half a
3727
+ // per-task call never falls through to the epic-wide answer.
3728
+ const claims = claimsForDispatch(flags);
3729
+ printJson(await findingsForDispatch({ sessionId, ...(flags.task === undefined ? {} : { taskId: flags.task }), claims }, eventOpts));
3162
3730
  return 0;
3163
3731
  }
3164
3732
  // Re-dating a finding's evidence (P9-15). Deliberately its own verb and not
@@ -3170,8 +3738,8 @@ async function main() {
3170
3738
  const ctx = eventContextFromFlags(flags);
3171
3739
  const eventOpts = eventOptsFromFlags(flags);
3172
3740
  requireSession(ctx.sessionId, eventOpts);
3173
- await reverifyFinding(findingId, flags.note ?? '', ctx, eventOpts);
3174
- printJson({ findingId, note: flags.note ?? '' });
3741
+ const reverified = await reverifyFinding(findingId, flags.note ?? '', ctx, eventOpts);
3742
+ printJson({ findingId, note: flags.note ?? '', event_id: reverified.event_id });
3175
3743
  return 0;
3176
3744
  }
3177
3745
  // D-21 Part 4. Corrects a malformed amends_task_ids entry on a finding
@@ -3211,18 +3779,34 @@ async function main() {
3211
3779
  printJson(pending);
3212
3780
  return 0;
3213
3781
  }
3782
+ if (namespace === 'waivers' && action === 'approve-task') {
3783
+ const [taskId] = requirePositionals(positional, usageFor('waivers approve-task'));
3784
+ const note = requireFlag(flags, 'note');
3785
+ const event = await approveTaskWaiver(taskId, note, eventContextFromFlags(flags), eventOptsFromFlags(flags));
3786
+ printJson(event);
3787
+ return 0;
3788
+ }
3214
3789
  if (namespace === 'waivers' && action === 'apply') {
3215
3790
  const [decisionsFile] = requirePositionals(positional, usageFor('waivers apply'));
3216
3791
  const decisions = readJsonFile(decisionsFile);
3217
3792
  const ctx = eventContextFromFlags(flags);
3218
- const results = await applyBatch(decisions, ctx, eventOptsFromFlags(flags));
3219
- printJson(results);
3793
+ const result = await applyBatch(decisions, ctx, eventOptsFromFlags(flags));
3794
+ // #221: a denial's output names the finding ids a later `plan amend
3795
+ // --findings` has to carry, or a denied finding stays open forever with
3796
+ // no discharge path left. See .claude/skills/bs/waivers.md.
3797
+ printJson(result);
3220
3798
  return 0;
3221
3799
  }
3222
3800
  if (namespace === 'lessons' && action === 'candidates') {
3223
3801
  const { openDb } = await import('./db/projector.js');
3224
3802
  const { lessonsPage } = await import('./db/queries.js');
3225
3803
  const dbPath = flags.db ?? STATE_DB_PATH;
3804
+ // A named session must exist, same as `db rebuild`/`db apply` (P9-28):
3805
+ // without this, a typoed --session silently reads as "no rows for this
3806
+ // session" instead of "no such session", and --state-dir (accepted here
3807
+ // for exactly this) had no effect at all.
3808
+ if (flags.session)
3809
+ requireSession(flags.session, eventOptsFromFlags(flags));
3226
3810
  const handle = openDb(dbPath);
3227
3811
  try {
3228
3812
  const scope = flags.session ? { sessionId: flags.session } : {};
@@ -3297,7 +3881,7 @@ async function main() {
3297
3881
  if (namespace === 'lessons' && action === 'compile') {
3298
3882
  const { openDb } = await import('./db/projector.js');
3299
3883
  const { lessonsPage } = await import('./db/queries.js');
3300
- const { compileLessons } = await import('./lessons.js');
3884
+ const { compileLessons, lessonsToPreserve } = await import('./lessons.js');
3301
3885
  const dbPath = flags.db ?? STATE_DB_PATH;
3302
3886
  // The operator's copy, not the package's: under an install the shipped one
3303
3887
  // lives in node_modules, which the next `npm i` replaces wholesale. mkdir
@@ -3310,7 +3894,14 @@ async function main() {
3310
3894
  try {
3311
3895
  const scope = flags.session ? { sessionId: flags.session } : {};
3312
3896
  const approved = lessonsPage(handle.db, scope).approved;
3313
- const markdown = compileLessons(approved.map((l) => ({
3897
+ // The store only ever tracks its OWN rows: an entry already in outPath
3898
+ // with no store row (hand-authored, or raised before the store existed)
3899
+ // is invisible to `approved` above, so compileLessons has to be handed
3900
+ // the file's own prior content or it has no way to tell "the store
3901
+ // doesn't track this" apart from "this was deleted" — see its own
3902
+ // docstring.
3903
+ const existingMarkdown = existsSync(outPath) ? readFileSync(outPath, 'utf8') : undefined;
3904
+ const compileInputs = approved.map((l) => ({
3314
3905
  lessonId: l.lessonId,
3315
3906
  lessonScope: l.lessonScope,
3316
3907
  statement: l.statement,
@@ -3318,9 +3909,14 @@ async function main() {
3318
3909
  claimPath: l.claimPath,
3319
3910
  agentRole: l.agentRole,
3320
3911
  caseType: l.caseType,
3321
- })));
3912
+ }));
3913
+ const markdown = compileLessons(compileInputs, existingMarkdown);
3322
3914
  writeFileSync(outPath, markdown, 'utf8');
3323
- printJson({ outPath, lessonsCompiled: approved.length });
3915
+ // lessonsPreserved: pre-existing entries with no store row that this
3916
+ // compile kept verbatim rather than regenerated — the count an operator
3917
+ // needs to notice a hand-authored entry silently stopped surviving.
3918
+ const lessonsPreserved = lessonsToPreserve(compileInputs, existingMarkdown).length;
3919
+ printJson({ outPath, lessonsCompiled: approved.length, lessonsPreserved });
3324
3920
  return 0;
3325
3921
  }
3326
3922
  finally {
@@ -3400,8 +3996,14 @@ async function main() {
3400
3996
  model: requireFlag(flags, 'model'),
3401
3997
  ...(flags.provider ? { provider: flags.provider } : {}),
3402
3998
  ...(flags['model-tier'] ? { modelTier: flags['model-tier'] } : {}),
3999
+ // uiux only: which of its two turns this dispatch opens; recordJudgeDispatch
4000
+ // rejects it for any other role.
4001
+ ...(flags.kind ? { kind: flags.kind } : {}),
3403
4002
  }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
3404
- printJson(stored);
4003
+ printJson({
4004
+ ...stored,
4005
+ expected_line: formatDeclaredArtifactLine(requireFlag(flags, 'artifact')),
4006
+ });
3405
4007
  return 0;
3406
4008
  }
3407
4009
  if (namespace === 'judge' && action === 'report') {
@@ -3419,16 +4021,27 @@ async function main() {
3419
4021
  // Here the role is already named by --role, so the valueless spelling
3420
4022
  // is the right one and 'true' means what it says.
3421
4023
  ...(flags['no-findings'] === 'true' ? { noFindings: true } : {}),
4024
+ // uiux only: which of its two open turns this closes; required when both are open.
4025
+ ...(flags.kind ? { kind: flags.kind } : {}),
3422
4026
  }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
3423
4027
  printJson(report);
3424
4028
  return 0;
3425
4029
  }
3426
4030
  if (namespace === 'judge' && action === 'outstanding') {
3427
4031
  const sessionId = requireFlag(flags, 'session');
4032
+ const taskId = requireFlag(flags, 'task');
3428
4033
  const eventOpts = eventOptsFromFlags(flags);
3429
4034
  requireSession(sessionId, eventOpts);
3430
- const open = outstandingJudges(await readJudgeTurns(requireFlag(flags, 'task'), { sessionId }, eventOpts));
3431
- printJson(open);
4035
+ // A typo such as `--kind visaul` must not read as "nothing outstanding":
4036
+ // filtering on an unrecognised kind would silently match zero turns and
4037
+ // exit 0 while a real one is still open (S2). Validate against the same
4038
+ // closed list `assertKindAllowed` checks before filtering.
4039
+ if (flags.kind !== undefined && !isJudgeKind(flags.kind)) {
4040
+ throw new JudgeError('judges.invalid-kind', `"--kind" must be "spec" or "visual"; got ${JSON.stringify(flags.kind)}.`, { kind: flags.kind });
4041
+ }
4042
+ const allOpen = outstandingJudges(await readJudgeTurns(taskId, { sessionId }, eventOpts));
4043
+ const open = flags.kind ? allOpen.filter((turn) => turn.kind === flags.kind) : allOpen;
4044
+ printJson({ taskId, sessionId, outstanding: open, count: open.length });
3432
4045
  return open.length > 0 ? 1 : 0;
3433
4046
  }
3434
4047
  if (namespace === 'judge' && action === 'escalations') {
@@ -3453,7 +4066,7 @@ async function main() {
3453
4066
  // Deliberately ahead of `judge run` in this file for the same reason it is
3454
4067
  // ahead of it in the runbook: an operator reaching for a calibration call
3455
4068
  // to find out why a provider keeps failing usually needed this instead.
3456
- const report = judgePreflight(flags.policy);
4069
+ const report = judgePreflight(flags.policy, { projectDir: flags.project });
3457
4070
  printJson(report);
3458
4071
  return report.problems.length > 0 ? 1 : 0;
3459
4072
  }
@@ -3473,7 +4086,15 @@ async function main() {
3473
4086
  // the split between them is the operator mandate made operable: `request`
3474
4087
  // shows exactly what would leave the machine WITHOUT sending it, `run`
3475
4088
  // sends it, and `reconcile` needs no provider at all.
3476
- const policy = loadCrosscheckPolicy(flags.policy);
4089
+ //
4090
+ // --project (or a --worktree to derive one from) reaches this load too,
4091
+ // same as `gate run`/`plan quorum`/`epic verdict`. Only `run` can ever
4092
+ // observe it: `independent_finder` -- the fragment `request`/`reconcile`
4093
+ // read -- is a repo-policy knob a project overlay is structurally
4094
+ // forbidden to touch (loadProjectProviderOverlay only ever merges
4095
+ // `providers`), and `run` is the one call that hands runIndependentFinder
4096
+ // the full policy, `providers` included.
4097
+ const policy = loadCrosscheckPolicy(flags.policy, { projectDir: projectDirFromFlags(flags) });
3477
4098
  if (action === 'request') {
3478
4099
  // Read-only and network-free by construction: it builds the JudgeRequest
3479
4100
  // and prints it. Every refusal independentFinderRequest() can raise —
@@ -3580,7 +4201,7 @@ async function main() {
3580
4201
  // missing feature but a silent data swap: the read path re-projects each
3581
4202
  // session on the first request (app.ts's createRefresher), and apply()
3582
4203
  // rebuilds the whole milestones table from a roadmap file while it is
3583
- // there. Unset, that file falls back to black-smith's own
4204
+ // there. Unset, that file falls back to blacksmith's own
3584
4205
  // factory/specs/roadmap.md — so serving another project's db showed this
3585
4206
  // repo's roadmap from the first page load onward.
3586
4207
  const roadmapPath = flags['roadmap-path'];
@@ -3588,13 +4209,30 @@ async function main() {
3588
4209
  // request, and the plan files it consults to place an unstamped epic
3589
4210
  // (D-246) must be the served db's, not this repo's.
3590
4211
  const specsDir = flags['specs-dir'];
4212
+ // Where Claude Code keeps its live-session registry and transcripts, for
4213
+ // /api/cli-sessions: --claude-config-dir, then $CLAUDE_CONFIG_DIR, then
4214
+ // ~/.claude. Only this verb resolves a default; the server never does.
4215
+ const claudeFlag = flags['claude-config-dir'];
4216
+ const claudeEnv = process.env.CLAUDE_CONFIG_DIR;
4217
+ const claude = claudeFlag
4218
+ ? { dir: path.resolve(claudeFlag), source: 'flag' }
4219
+ : claudeEnv
4220
+ ? { dir: path.resolve(claudeEnv), source: 'env' }
4221
+ : { dir: path.join(homedir(), '.claude'), source: 'default' };
4222
+ // Repeatable: extra state homes (`<dir>/state/events`) the dashboard reads
4223
+ // besides the live sessions' own. Read-only; see ui/server/src/stores.ts.
4224
+ const stores = (repeated.store ?? []).map((dir) => path.resolve(dir));
3591
4225
  mod.serve({
3592
4226
  port,
3593
4227
  dbPath,
4228
+ ...(stores.length > 0 ? { stores } : {}),
3594
4229
  ...(stateDir ? { stateDir } : {}),
3595
4230
  ...(roadmapPath ? { roadmapPath } : {}),
3596
4231
  ...(specsDir ? { specsDir } : {}),
3597
4232
  ...(nowIso !== undefined ? { nowIso } : {}),
4233
+ claudeConfigDir: claude.dir,
4234
+ claudeConfigSource: claude.source,
4235
+ knownRoots: [...new Set([REPO_ROOT, WORK_ROOT])],
3598
4236
  });
3599
4237
  return 0;
3600
4238
  }
@@ -3699,11 +4337,63 @@ async function main() {
3699
4337
  });
3700
4338
  return 1;
3701
4339
  }
4340
+ // `smith <verb> | head -c1` (or any reader that goes away before draining
4341
+ // stdout) closes the pipe out from under a write that has already committed
4342
+ // whatever the verb was reporting -- a `dispatch_decision` appended, an event
4343
+ // tailed, a report already computed. Node's default for an unheard stdout
4344
+ // `error` is to throw, which crashes the process AFTER that commit with an
4345
+ // uncaught `write EPIPE` stack; a caller that treats the resulting non-zero
4346
+ // exit as "did not happen" and retries then double-appends. `event tail
4347
+ // --follow` and `daemon run` already each register their own `process.stdout
4348
+ // .on('error', requestStop)` to stop their loop quietly (cli.ts's two other
4349
+ // stdout error listeners), but every one-shot verb -- everything that calls
4350
+ // `printJson` exactly once -- had no listener at all, so it was still one
4351
+ // early reader away from this crash. Registered here, at the top of the
4352
+ // file's only entry point, so it is in place before ANY write, including the
4353
+ // per-verb ones' own first `printJson` call.
4354
+ //
4355
+ // Multiple listeners on the same stream's `error` event all fire -- this one
4356
+ // does not replace `event tail --follow`'s or `daemon run`'s, it just also
4357
+ // catches everything they do not: whichever fires first, an EPIPE is still
4358
+ // quiet.
4359
+ //
4360
+ // A real write failure (ENOSPC, say) is not innocuous like EPIPE, but this
4361
+ // handler is registered before `event tail --follow`'s and `daemon run`'s
4362
+ // own `process.stdout.on('error', requestStop)` listeners (this file's other
4363
+ // two), at the top of the file's only entry point -- so if it rethrows
4364
+ // unconditionally, its throw fires first and those listeners never run at
4365
+ // all. For `daemon run` specifically, that throw becomes an uncaught
4366
+ // exception outside `runDaemon`'s own try/finally (daemon.ts's loop), which
4367
+ // skips the finally and strands the lock under a pid that is already gone --
4368
+ // exactly what the comment at this file's `daemon run` handler says the
4369
+ // listener there exists to prevent. `listenerCount` counts this listener
4370
+ // too, so `> 1` means one of those loop-owning listeners is also registered:
4371
+ // defer to it and let its own stop-the-loop handling run instead of
4372
+ // rethrowing here. Only when this is the sole listener -- a one-shot verb,
4373
+ // nothing else to stop -- does a real write failure still throw and crash,
4374
+ // same as before.
4375
+ let mainExitCode = null;
4376
+ process.stdout.on('error', (err) => {
4377
+ if (err.code === 'EPIPE') {
4378
+ // The command's own resolved status when we already have one (a verb
4379
+ // that computed its exit code before this write, the common case); 0 --
4380
+ // "wrote everything there was, the reader just stopped listening" --
4381
+ // when main() has not resolved yet, since an early EPIPE is the reader
4382
+ // saying "enough", not this process failing to do its job.
4383
+ process.exitCode = mainExitCode ?? 0;
4384
+ return;
4385
+ }
4386
+ if (process.stdout.listenerCount('error') > 1)
4387
+ return;
4388
+ throw err;
4389
+ });
3702
4390
  main()
3703
4391
  .then((code) => {
4392
+ mainExitCode = code;
3704
4393
  process.exitCode = code;
3705
4394
  })
3706
4395
  .catch((err) => {
4396
+ mainExitCode = 1;
3707
4397
  // A SmithError is a designed answer: its code names the failure and its
3708
4398
  // details name the record, so a stack would only add noise. Anything else
3709
4399
  // is a bug, and D-135 is what that costs — `{"message":"Cannot read