@juzser/blacksmith 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +143 -50
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +70 -13
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. package/.claude/agents/uiux.md +0 -133
@@ -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,19 +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';
35
+ import { lintDispatchPrompt } from './dispatchLint.js';
32
36
  import { loadDotEnv } from './dotenv.js';
33
37
  import { loadEffortPolicy, resolveEffort } from './effort.js';
38
+ import { budgetPolicyForPlan, epicBudgetPolicies } from './epicBudget.js';
34
39
  import { ISSUE_CANDIDATE_EVENT_TYPES } from './errorIssues.js';
35
40
  import { SmithError } from './errors.js';
36
41
  import { checkEscalationLadder } from './escalation.js';
37
42
  import { appendEvent, filterEvents, followEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
43
+ import { listPendingFeedback, recordFeedback, resolveFeedback, } from './feedback.js';
38
44
  import { findingsForDispatch } from './findingContext.js';
39
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';
40
47
  import { recordGoalCheck, resolveEpicGoal } from './goalCheck.js';
41
48
  import { loadHarnessPolicy, planWorkerTurn, summarizeHarnesses, } from './harness.js';
42
49
  import { decideHookPayload } from './hookDecision.js';
@@ -45,45 +52,104 @@ import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
45
52
  import { integrationHeadSha, runIntegrationCheck } from './integration.js';
46
53
  import { previewOutcomes, reportErrors } from './issueReporter.js';
47
54
  import { judgePreflight } from './judgePreflight.js';
48
- import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
55
+ import { isJudgeKind, JudgeError, outstandingJudges, readJudgeArtifact, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, uiuxDeviationsToEvidence, } from './judges.js';
49
56
  import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
50
- import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
51
- import { diffPlans, livePlanTasks, 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';
52
59
  import { runPlanQuorum } from './planQuorum.js';
53
60
  import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
54
61
  import { factoryProjects } from './projects.js';
62
+ import { captureContext, capturePrompt } from './promptCapture.js';
63
+ import { importTranscript } from './promptImport.js';
55
64
  import { recordUserPrompt } from './prompts.js';
56
65
  import { checkBrief, wrapIngested } from './provenance.js';
57
66
  import { runJudge } from './providers/index.js';
58
- import { admit, adopt, step } from './queue.js';
67
+ import { admit, adopt, batchStep, groupForBatch, step } from './queue.js';
59
68
  import { stampResultEnvelope } from './results.js';
60
- import { isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
69
+ import { FACTORY_PROJECT, isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
61
70
  import { checkRuntime } from './runtime.js';
62
71
  import { checkSameMistakeKpi } from './sameMistakeKpi.js';
63
72
  import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './sandbox.js';
64
73
  import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
65
74
  import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
66
75
  import { parseLessons } from './severity.js';
67
- import { amendPlan, recordSpecReview } from './spec.js';
76
+ import { amendPlan, recordSpecReview, successorChainPath, taskSuccessors } from './spec.js';
68
77
  import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
69
78
  import { checkStack, loadStackAnswers } from './stack.js';
70
79
  import { buildSymbolGraph, collectSources } from './symbols.js';
71
- 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';
72
83
  import { loadTaxonomy } from './taxonomy.js';
73
84
  import { checkTesterIsolation } from './testerAudit.js';
74
85
  import { assertSelectableTestCmd } from './testSelect.js';
75
86
  import { COMMANDS, flagSpecFor, helpText, isDocumented, positionalNames, usageFor, usageLine, usageText, } from './usage.js';
76
- import { applyBatch, pendingBatch } from './waivers.js';
87
+ import { applyBatch, approveTaskWaiver, pendingBatch, } from './waivers.js';
77
88
  import { blocksAdmission, checkWaveBudget, } from './waveBudget.js';
78
89
  import { computeNextWave, liveWaveTasks } from './waveNext.js';
79
90
  import { initWorkRoot } from './workroot.js';
80
- 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';
81
92
  function printJson(value) {
82
93
  process.stdout.write(`${JSON.stringify(value)}\n`);
83
94
  }
84
95
  function readJsonFile(filePath) {
85
96
  return JSON.parse(readFileSync(filePath, 'utf8'));
86
97
  }
98
+ /**
99
+ * `--checks <file>`'s intake, both places it's read (`gate run`,
100
+ * `integration check`). Same fail-closed shape as `boundedIntFlag`, moved
101
+ * from a flag to a JSON field: a check's own `timeout_ms` goes straight into
102
+ * a `setTimeout` call in testgate.ts, and a non-positive or non-integer value
103
+ * there does not fail loudly — it fails as either an instant, unexplained
104
+ * timeout or one that silently never fires. Above 2^31-1 Node clamps the
105
+ * delay to 1ms, so an oversized value is the instant kind too. Refused here,
106
+ * at the point the file is read, rather than let a gate run act on either.
107
+ */
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
+ }
141
+ function readChecksFile(filePath) {
142
+ const checks = readJsonFile(filePath);
143
+ for (const check of checks) {
144
+ const timeoutMs = check.timeout_ms;
145
+ if (timeoutMs === undefined)
146
+ continue;
147
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_CHECK_TIMEOUT_MS) {
148
+ throw new SmithError('gate.invalid-check-timeout', `"${check.name}" in ${filePath} has timeout_ms ${JSON.stringify(timeoutMs)}; it must be a positive whole number of milliseconds, at most ${MAX_CHECK_TIMEOUT_MS}.`, { name: check.name, timeout_ms: timeoutMs, file: filePath });
149
+ }
150
+ }
151
+ return checks;
152
+ }
87
153
  /** A task spec's `budget.tokens` when it is a usable positive number, else null. */
88
154
  function declaredTokens(budget) {
89
155
  if (typeof budget !== 'object' || budget === null)
@@ -142,6 +208,9 @@ function declaredWaveBudgets(plan, taskIds, policy) {
142
208
  function admissionBudget(check, rationale) {
143
209
  return {
144
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,
145
214
  cap_tokens: check.capTokens,
146
215
  projected_tokens: check.projectedTokens,
147
216
  wave_tokens: check.waveTokens,
@@ -157,23 +226,41 @@ function requireFlag(flags, name) {
157
226
  return value;
158
227
  }
159
228
  /**
160
- * requireFlag for a flag that must parse as a number, so `--input-tokens abc`
161
- * is named here rather than travelling as NaN into a token_usage the schema
162
- * 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.
163
243
  */
164
- function requireIntFlag(flags, name) {
165
- const raw = requireFlag(flags, name);
166
- const value = Number(raw);
167
- if (!Number.isInteger(value)) {
168
- throw new SmithError('cli.non-numeric-flag', `--${name} must be an integer, got "${raw}".`, {
169
- flag: name,
170
- value: raw,
171
- });
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;
172
256
  }
173
- return value;
174
257
  }
175
258
  /**
176
- * 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.
177
264
  *
178
265
  * D-210. `event tail --n` read its count with a bare Number.parseInt and
179
266
  * checked nothing, which fails in both directions at once: `--n abc` is NaN,
@@ -333,20 +420,148 @@ function scopeIssueCandidates(events, flags) {
333
420
  return since === undefined || record.ts >= since;
334
421
  });
335
422
  }
336
- /** Both `issues` actions read the same inputs; only the entry point differs. */
423
+ /**
424
+ * Both `issues` actions read the same inputs; only the entry point differs.
425
+ *
426
+ * `resolveProject` and `selfFallback` together close the privacy-leak gap an
427
+ * unstamped row otherwise falls into: a session or plan driving a project
428
+ * other than this factory writes error/gate/task-added rows with no
429
+ * top-level `project` stamp. Both answer from the row's own task ref's
430
+ * epic's plan file (D-246's `db/projector.ts` precedent, restated here
431
+ * without a DB dependency `cli.ts`'s boot graph cannot carry) -- a plan on
432
+ * disk naming a project wins outright via `resolveProject`; a plan on disk
433
+ * naming none is this checkout's own epic, so `selfFallback` resolves it to
434
+ * `FACTORY_PROJECT`; no plan found at all (a foreign epic, or a bare ref)
435
+ * stays `null` from both, and `reportErrors`/`previewOutcomes` skip that row
436
+ * with `skipped-unresolved-project` rather than ever defaulting it into this
437
+ * factory's own repository.
438
+ *
439
+ * The two are kept as SEPARATE resolvers, not one combined
440
+ * `planProjectResolverForTaskRefOrSelf`, so `withSessionFallback`
441
+ * (`errorIssues.ts`) can try them as distinct tiers with a session's own
442
+ * stamp riding between them: `resolveProject` first, then any `project`
443
+ * stamp elsewhere in the same session (or a bare ref's `task-added`
444
+ * `epic_id` re-asked of `resolveProject`), and only then `selfFallback`
445
+ * (S2-c) -- a session-mate's real stamp naming a different project must
446
+ * outrank this weakest, locally-guessed signal, which a single combined
447
+ * resolver tried as one first tier could not guarantee.
448
+ *
449
+ * `--specs-dir` (via `planOptsFromFlags`) and `--state-dir` (via
450
+ * `eventOptsFromFlags`) are two independent flags on purpose: `--state-dir`
451
+ * says where THIS session's own events live, `--specs-dir` says where the
452
+ * epics IT references keep their plans, and a session's events and the
453
+ * plans its tasks belong to are not always colocated (the daemon's own
454
+ * event log and a target checkout's specs tree, for one). Requiring them to
455
+ * travel together would make `issues report` unable to point one flag at a
456
+ * remote-events state dir while resolving projects from this checkout's own
457
+ * specs/active, which is `smith`'s ordinary daemon posture. Callers who do
458
+ * mean the same tree for both pass the same path to both flags.
459
+ *
460
+ * `lineageEvents` is the session's UNSCOPED lineage, read before
461
+ * `scopeIssueCandidates` narrows `events` to `--epic`/`--since` (S3):
462
+ * `ORIGIN_STAMP_EVENT_TYPES` overlaps the scoped `ISSUE_CANDIDATE_EVENT_TYPES`
463
+ * set, so `sessionProjectStamps` reading straight off the narrowed `events`
464
+ * could lose a session-mate's stamp purely because `--epic`/`--since`
465
+ * excluded that row, changing an unrelated in-scope row's resolved project
466
+ * -- and therefore its fingerprint -- based only on which scoping flags were
467
+ * passed. `reportErrors`/`previewOutcomes` fold `events` for candidates but
468
+ * consult `lineageEvents` for the session-stamp tier, so `--epic`/`--since`
469
+ * narrow what gets reported without perturbing how a row's project is
470
+ * resolved.
471
+ */
337
472
  async function issueInputs(flags) {
338
473
  const sessionId = requireFlag(flags, 'session');
339
474
  const eventOpts = eventOptsFromFlags(flags);
340
475
  requireSession(sessionId, eventOpts);
341
- const events = scopeIssueCandidates(await readLineageEvents(sessionId, eventOpts), flags);
476
+ const lineageEvents = await readLineageEvents(sessionId, eventOpts);
477
+ const events = scopeIssueCandidates(lineageEvents, flags);
342
478
  const milestones = loadRoadmap(flags['roadmap-path']);
343
479
  const isEnabled = (project) => isErrorTrackerWritable(milestones, project);
344
- return { events, isEnabled, register: factoryProjects(), eventOpts };
480
+ const planOpts = planOptsFromFlags(flags);
481
+ const resolveProject = planProjectResolverForTaskRef(planOpts);
482
+ const selfFallback = planSelfFallbackForTaskRef(FACTORY_PROJECT, planOpts);
483
+ return {
484
+ events,
485
+ isEnabled,
486
+ register: factoryProjects(),
487
+ eventOpts,
488
+ resolveProject,
489
+ selfFallback,
490
+ lineageEvents,
491
+ };
345
492
  }
346
- /** Where plan version files are read from and written to; defaults to factory/specs/active. */
493
+ /**
494
+ * Where plan version files are read from and written to; defaults to
495
+ * factory/specs/active. See `issueInputs`'s doc comment for why this
496
+ * deliberately does not travel with `--state-dir`.
497
+ */
347
498
  function planOptsFromFlags(flags) {
348
499
  return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
349
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
+ }
350
565
  /**
351
566
  * The one reader for every flag usage.ts documents as `<iso>` (D-209).
352
567
  *
@@ -403,10 +618,10 @@ function isoDateFlag(flags, name) {
403
618
  * `event append` writes.
404
619
  *
405
620
  * `--roadmap-path` belongs here too: both verbs rebuild the whole milestones
406
- * 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
407
622
  * factory/specs/roadmap.md. Dropping the flag therefore did not mean "keep the
408
623
  * existing milestones" — it meant "replace this db's milestones with
409
- * 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.
410
625
  *
411
626
  * `--specs-dir` travels for the same reason and is the same flag `plan`
412
627
  * already takes: the projector reads an epic's plan file to answer what
@@ -574,6 +789,25 @@ function mintFromEvidence(args, taskId, scope = {}) {
574
789
  ...scope,
575
790
  }));
576
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
+ }
577
811
  /**
578
812
  * The gated task's declared caps, read off the same `--plan` the gate already
579
813
  * takes for ownership (D-41/P9-24) — no new flag, because a budget the gate can
@@ -598,9 +832,33 @@ function budgetFromFlags(flags, taskId) {
598
832
  return undefined;
599
833
  throw err;
600
834
  }
601
- const budget = plan.tasks.find((t) => t.task_id === resolved)?.budget;
835
+ const budget = liveSpec(plan, resolved)?.budget;
602
836
  return typeof budget === 'object' && budget !== null ? budget : undefined;
603
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
+ }
604
862
  /**
605
863
  * The claim globs one dispatch is scoped to, read off the plan (P9-2). Only
606
864
  * claim-path-scoped lessons use them, so a role that declares no such scope
@@ -612,7 +870,21 @@ function claimsForDispatch(flags) {
612
870
  if (!flags.plan)
613
871
  return [];
614
872
  const taskId = requireFlag(flags, 'task');
615
- 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);
616
888
  if (!owner) {
617
889
  throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
618
890
  taskId,
@@ -636,7 +908,21 @@ function caseForDispatch(flags) {
636
908
  if (!flags.plan)
637
909
  return '';
638
910
  const taskId = requireFlag(flags, 'task');
639
- 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);
640
926
  if (!task) {
641
927
  throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
642
928
  taskId,
@@ -694,6 +980,47 @@ function splitNamespaceAction(argv) {
694
980
  }
695
981
  return { namespace, action: undefined, rest: restAll };
696
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
+ }
697
1024
  /**
698
1025
  * Everything a wave computation reads about a plan: the plan itself with the
699
1026
  * log's follow-ups merged in, the worktree policy, the import crossings
@@ -732,8 +1059,19 @@ async function nextWaveInputFrom(planFile, flags) {
732
1059
  ? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
733
1060
  : [];
734
1061
  const planIds = new Set(planOnDisk.tasks.map((t) => t.task_id));
1062
+ // A lineage is not one epic. `readAddedTasks` reads the WHOLE lineage
1063
+ // (D-119), so a resumed session that ever ran a different, earlier epic
1064
+ // carries that epic's `task-added` rows too — and a row an operator
1065
+ // abandoned non-terminal (never merged, waived, or superseded) reads back
1066
+ // here exactly like a genuine follow-up of THIS plan. Unfiltered, it was
1067
+ // spliced into `plan.tasks` below and reached `computeNextWave` as a
1068
+ // candidate or an occupied claim-holder: proposed as this epic's own work,
1069
+ // and able to defer this epic's tasks on a claim overlap that has nothing
1070
+ // to do with them. `wave check` never has this problem because it only
1071
+ // ever looks at the ids an operator names — matching that, a follow-up
1072
+ // only counts here when its own `task-added` named this plan's epic.
735
1073
  const followUps = logged
736
- .filter((t) => !planIds.has(t.taskId))
1074
+ .filter((t) => !planIds.has(t.taskId) && t.epicId === planOnDisk.epic_id)
737
1075
  .map((t) => ({
738
1076
  task_id: t.taskId,
739
1077
  // The log is the only register that holds this task's status, so a
@@ -770,6 +1108,10 @@ async function nextWaveInputFrom(planFile, flags) {
770
1108
  };
771
1109
  }
772
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');
773
1115
  // Refuse an unsupported runtime before anything opens the database. The
774
1116
  // native binding crashes lazily — `new Database()`, not import — so a check
775
1117
  // here still runs, and a subcommand that happens to avoid SQLite must not
@@ -779,7 +1121,7 @@ async function main() {
779
1121
  if (!runtime.supported) {
780
1122
  // Prose on stderr so a human sees it even when stdout is piped into a
781
1123
  // JSON parser; the structured form still goes to stdout for callers.
782
- process.stderr.write(`smith: unsupported runtime\n${runtime.reason ?? ''}\n`);
1124
+ process.stderr.write(`bs: unsupported runtime\n${runtime.reason ?? ''}\n`);
783
1125
  printJson({ error: { code: 'unsupported-runtime', message: runtime.reason } });
784
1126
  return 1;
785
1127
  }
@@ -898,7 +1240,20 @@ async function main() {
898
1240
  }
899
1241
  if (namespace === 'audit' && action === 'resolve') {
900
1242
  const [projectDir] = requirePositionals(positional, usageFor('audit resolve'));
901
- printJson(await resolveAudit(projectDir, requireFlag(flags, 'epic'), eventContextFromFlags(flags), eventOptsFromFlags(flags)));
1243
+ // Comma-split, not repeated: a fingerprint has no commas, and deferring
1244
+ // more than one at a time is the common case (D-33's precedent, `plan
1245
+ // amend --findings`).
1246
+ const except = flags.except
1247
+ ? flags.except
1248
+ .split(',')
1249
+ .map((fp) => fp.trim())
1250
+ .filter((fp) => fp !== '')
1251
+ : undefined;
1252
+ printJson(await resolveAudit(projectDir, requireFlag(flags, 'epic'), eventContextFromFlags(flags), eventOptsFromFlags(flags), {
1253
+ ...(except ? { except } : {}),
1254
+ ...(flags.plan ? { plan: readJsonFile(flags.plan) } : {}),
1255
+ planOpts: planOptsFromFlags(flags),
1256
+ }));
902
1257
  return 0;
903
1258
  }
904
1259
  if (namespace === 'audit' && action === 'close') {
@@ -911,7 +1266,25 @@ async function main() {
911
1266
  // requirePositionals is what makes it true, and it runs first.
912
1267
  const [planFile] = requirePositionals(positional, usageFor('plan validate'));
913
1268
  const plan = readJsonFile(planFile);
914
- 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 } : {});
915
1288
  printJson(result);
916
1289
  return result.valid ? 0 : 1;
917
1290
  }
@@ -973,6 +1346,13 @@ async function main() {
973
1346
  ...(draft ? { plan: draft } : {}),
974
1347
  ...(plannerConfidence !== undefined ? { plannerConfidence } : {}),
975
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 }) },
976
1356
  }, ctx, eventOptsFromFlags(flags));
977
1357
  printJson(outcome);
978
1358
  // FD-49: the outcome is a few rationales of several kB each, and the
@@ -995,7 +1375,12 @@ async function main() {
995
1375
  const plan = readJsonFile(planFile);
996
1376
  const ctx = eventContextFromFlags(flags);
997
1377
  const opts = eventOptsFromFlags(flags);
998
- 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);
999
1384
  // D-254: the arrows, after the nodes. The plan declares a DAG and the
1000
1385
  // scheduler has always read it off the file, but nothing wrote it to the
1001
1386
  // log -- so the db's `edges` table was empty and every operator-facing
@@ -1004,6 +1389,19 @@ async function main() {
1004
1389
  // what went unnoticed.
1005
1390
  const edges = await emitEdgesRecorded(plan, ctx, opts);
1006
1391
  const added = written.filter((e) => e.record.event_type === 'task-added').length;
1392
+ // Criterion 7: a plan that serializes by claim geometry alone used to be
1393
+ // invisible until `/bs run` actually hit the ceiling. Dynamic for the
1394
+ // same reason `wave schedule` imports it dynamically -- nothing an
1395
+ // ingest that never schedules needs should be on the boot path every
1396
+ // `smith --help` walks. No repo scan here: `parallel_with` is
1397
+ // claims-and-edges only, so the input carries no crossings.
1398
+ const { computePlanParallelism } = await import('./waveSchedule.js');
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];
1007
1405
  printJson({
1008
1406
  epic: plan.epic_id,
1009
1407
  version: plan.version,
@@ -1011,6 +1409,8 @@ async function main() {
1011
1409
  superseded: written.length - added,
1012
1410
  skipped: plan.tasks.length - added,
1013
1411
  edges: edges.length,
1412
+ parallelism,
1413
+ ...(lastEvent ? { event_id: lastEvent.event_id } : {}),
1014
1414
  });
1015
1415
  return 0;
1016
1416
  }
@@ -1018,7 +1418,8 @@ async function main() {
1018
1418
  // to cut a version that cites no spec finding, so "the plan changed" is
1019
1419
  // always answerable with "which finding said it was wrong".
1020
1420
  if (namespace === 'plan' && action === 'amend') {
1021
- const plan = readJsonFile(requireFlag(flags, 'plan'));
1421
+ const planPath = requireFlag(flags, 'plan');
1422
+ const plan = readJsonFile(planPath);
1022
1423
  // Comma-split, not repeated: a finding id has no commas, and an amendment
1023
1424
  // routinely cites several at once.
1024
1425
  const findingIds = requireFlag(flags, 'findings')
@@ -1038,7 +1439,7 @@ async function main() {
1038
1439
  rationale: requireFlag(flags, 'rationale'),
1039
1440
  sites,
1040
1441
  ...(changes ? { changes } : {}),
1041
- }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
1442
+ }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...amendSpecsDirOpts(planPath, plan, flags) });
1042
1443
  // No no-op warning here any more. A pure carry-forward used to be legal
1043
1444
  // and merely suspicious — the shape a forgotten --changes takes — so the
1044
1445
  // CLI printed a warning and cut the version anyway. Since D-127 an
@@ -1059,6 +1460,11 @@ async function main() {
1059
1460
  // no task behind it is a deliberate call or a forgotten one.
1060
1461
  sitesUnclaimed: result.sitesUnclaimed,
1061
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,
1062
1468
  });
1063
1469
  return 0;
1064
1470
  }
@@ -1108,13 +1514,17 @@ async function main() {
1108
1514
  // sites. Approval supplies them from what the worker already recorded.
1109
1515
  if (namespace === 'plan' && action === 'approve') {
1110
1516
  const [proposalId] = requirePositionals(positional, usageFor('plan approve'));
1111
- 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.
1112
1522
  const result = await approveSpecChange({
1113
1523
  proposalId,
1114
1524
  plan,
1115
1525
  decidedBy: requireFlag(flags, 'decided-by'),
1116
1526
  ...(flags.rationale ? { rationale: flags.rationale } : {}),
1117
- }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
1527
+ }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...amendSpecsDirOpts(planPath, plan, flags) });
1118
1528
  printJson({
1119
1529
  proposalId,
1120
1530
  epic: result.plan.epic_id,
@@ -1124,6 +1534,7 @@ async function main() {
1124
1534
  sites: result.proposal.sites,
1125
1535
  sitesUnclaimed: result.sitesUnclaimed,
1126
1536
  diff: result.diff,
1537
+ event_id: result.event_id,
1127
1538
  });
1128
1539
  return 0;
1129
1540
  }
@@ -1153,7 +1564,7 @@ async function main() {
1153
1564
  // at. resolveTaskId turns that silence into a named error.
1154
1565
  const typedIds = positional.slice(1);
1155
1566
  if (typedIds.length === 0) {
1156
- 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 });
1157
1568
  }
1158
1569
  // D-48/P9-31: a follow-up task exists only in the log, so the log is the
1159
1570
  // second register an id may be found in — and the place its claims come
@@ -1163,6 +1574,8 @@ async function main() {
1163
1574
  ? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
1164
1575
  : [];
1165
1576
  const taskIds = typedIds.map((typed) => resolveTaskId(plan, typed, logged.map((t) => t.taskId)));
1577
+ if (flags.session)
1578
+ await refuseUnadmissibleTasks(plan, taskIds, flags);
1166
1579
  // Neither register is narrowed on the way in. `?? []` used to stand here
1167
1580
  // and on the line above, and it fires on a missing claims field but not on
1168
1581
  // a claims field holding the wrong thing: a plan writing `"claims":
@@ -1230,7 +1643,9 @@ async function main() {
1230
1643
  // the sum of every session that worked on it.
1231
1644
  await readLineageEvents(flags.session, eventOptsFromFlags(flags))
1232
1645
  : [];
1233
- 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'] });
1234
1649
  const budget = checkWaveBudget(budgetEvents, budgetPolicy, declaredWaveBudgets(plan, taskIds, budgetPolicy), { sessionId: flags.session ?? '', epicId: plan.epic_id });
1235
1650
  const overridden = rationale !== '' && blocksAdmission(budget.status);
1236
1651
  const blocked = blocksAdmission(budget.status) && !overridden;
@@ -1244,7 +1659,7 @@ async function main() {
1244
1659
  }
1245
1660
  // Names only, and only when some differ from budgets.yml: which caps came
1246
1661
  // from the box's env rather than the file.
1247
- const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}));
1662
+ const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}, budgetPolicy.tier));
1248
1663
  printJson({
1249
1664
  ...result,
1250
1665
  symbolImpact,
@@ -1280,9 +1695,13 @@ async function main() {
1280
1695
  // Static would be fine — waveSchedule.js reaches nothing waveNext.js has
1281
1696
  // not already put on the boot path — but the input assembly it is handed
1282
1697
  // may dynamically import `db/projector.js`, so the await is here anyway.
1283
- const { scheduleWaves } = await import('./waveSchedule.js');
1284
- const schedule = scheduleWaves(await nextWaveInputFrom(planFile, flags));
1285
- printJson(schedule);
1698
+ const { computePlanParallelism, scheduleWaves } = await import('./waveSchedule.js');
1699
+ const input = await nextWaveInputFrom(planFile, flags);
1700
+ const schedule = scheduleWaves(input);
1701
+ // Same `size` block `plan ingest` prints under `parallelism`, so the
1702
+ // operator sees the small-epic bound from either command.
1703
+ const { size } = computePlanParallelism(input.plan, input.policy);
1704
+ printJson({ ...schedule, size });
1286
1705
  // Writes nothing, for the reason `wave next` writes nothing and one more:
1287
1706
  // every round after the first is a simulation. The tasks it marks complete
1288
1707
  // were completed by nobody, and a log that recorded them would be claiming
@@ -1401,7 +1820,21 @@ async function main() {
1401
1820
  const projectDirs = resolveProjectDirs(repeated.project, {
1402
1821
  self: flags['no-self'] !== 'true',
1403
1822
  });
1404
- const input = { events, now, projectDirs };
1823
+ // Same resolver `issues report`/`preview` and the daemon use (D-246): an
1824
+ // error-report proposal's fingerprint is hashed over its `project`
1825
+ // (computeFingerprint), so this verb has to resolve a row's project the
1826
+ // same way the reporter that later clears the proposal does. A divergent
1827
+ // resolver here -- or none, which folds every row to `project: null` --
1828
+ // hashes a different fingerprint than the reporter's `issue-reported`
1829
+ // event names, so the proposal `scheduler run` keeps proposing never
1830
+ // matches the one already answered and is re-appended forever (S1-b).
1831
+ const input = {
1832
+ events,
1833
+ now,
1834
+ projectDirs,
1835
+ resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
1836
+ selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
1837
+ };
1405
1838
  if (dry) {
1406
1839
  printJson({ proposals: computeProposals(input) });
1407
1840
  return 0;
@@ -1424,7 +1857,8 @@ async function main() {
1424
1857
  // the whole safety property: an operator can read what the policy would
1425
1858
  // have let run, and argue with it, before any of it moves. So this appends
1426
1859
  // no event, writes no worktree and starts no agent -- exactly the daemon's
1427
- // 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.
1428
1862
  if (namespace === 'scheduler' && action === 'admit') {
1429
1863
  const { computeProposals, loadSchedulerPolicy } = await import('./scheduler.js');
1430
1864
  const { admitProposals } = await import('./autonomy.js');
@@ -1448,11 +1882,17 @@ async function main() {
1448
1882
  // This clone joins the pass by default (contract clause 1); `--no-self`
1449
1883
  // is the refusal path.
1450
1884
  const { resolveProjectDirs } = await import('./projects.js');
1885
+ // Same resolver as `scheduler run` above, for the same reason (S1-b): a
1886
+ // divergent or absent resolver hashes a different fingerprint than the
1887
+ // reporter's `issue-reported` event names, so an already-answered
1888
+ // error-report proposal never clears.
1451
1889
  const proposals = computeProposals({
1452
1890
  events,
1453
1891
  now,
1454
1892
  policy,
1455
1893
  projectDirs: resolveProjectDirs(repeated.project, { self: flags['no-self'] !== 'true' }),
1894
+ resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
1895
+ selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
1456
1896
  });
1457
1897
  // A RecheckProposal names a task and no paths, so without this the
1458
1898
  // security match would see an opaque id and clear a recheck of
@@ -1501,6 +1941,16 @@ async function main() {
1501
1941
  ...eventOptsFromFlags(flags),
1502
1942
  projectDirs: resolveProjectDirs(repeated.project, { self }),
1503
1943
  readProjects: () => factoryProjects().filter((ref) => self || !ref.self),
1944
+ // Same D-246 self-fallback as issueInputs (see its doc comment): a
1945
+ // plan on disk naming no project is this checkout's own epic, so it
1946
+ // reads as FACTORY_PROJECT rather than staying an unresolved null the
1947
+ // daemon would report as an unlabeled finding and the on/off switch
1948
+ // for 'blacksmith' would never get asked about. Split into strict
1949
+ // (resolveProjectForTaskRef) and self-only (selfFallbackForTaskRef)
1950
+ // tiers so a session's own stamp can ride between them (S2-c).
1951
+ resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
1952
+ selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
1953
+ planOpts: planOptsFromFlags(flags),
1504
1954
  ...(flags.db ? { dbPath: flags.db } : {}),
1505
1955
  ...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
1506
1956
  };
@@ -1556,7 +2006,7 @@ async function main() {
1556
2006
  // Let acquireLock own the liveness question — one implementation of
1557
2007
  // "is that pid still there" rather than a second opinion here.
1558
2008
  throw new DaemonError('daemon.already-running', `A daemon (pid ${held.pid}, started ${held.startedAt}) already holds ${dir}. ` +
1559
- '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 });
1560
2010
  }
1561
2011
  mkdirSync(dir, { recursive: true });
1562
2012
  // `ignore` would discard the one account of why a detached daemon died.
@@ -1636,6 +2086,46 @@ async function main() {
1636
2086
  }
1637
2087
  if (namespace === 'worktree' && action === 'create') {
1638
2088
  const [projectDir, epic, taskId] = requirePositionals(positional, usageFor('worktree create'));
2089
+ if (flags.from !== undefined) {
2090
+ // `--from` reuses a predecessor's commits, so it may only be spent on a
2091
+ // task the log actually names as that predecessor's successor — never
2092
+ // on a bare "I want the same branch history" request. `--session` is
2093
+ // what supplies the log to check against; without it there is nothing
2094
+ // to refuse against, so demand it explicitly rather than silently
2095
+ // trusting an unauthenticated `--from`.
2096
+ const sessionId = requireFlag(flags, 'session');
2097
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
2098
+ const successors = taskSuccessors(events, epic);
2099
+ const from = flags.from;
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) {
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 });
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
+ }
2126
+ printJson(createTaskWorktree(projectDir, epic, taskId, { from }));
2127
+ return 0;
2128
+ }
1639
2129
  printJson(createTaskWorktree(projectDir, epic, taskId));
1640
2130
  return 0;
1641
2131
  }
@@ -1712,6 +2202,110 @@ async function main() {
1712
2202
  printJson({ event_id: stored.event_id, record: stored.record });
1713
2203
  return 0;
1714
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
+ }
1715
2309
  if (namespace === 'research' && action === 'check') {
1716
2310
  const result = checkBrief(readJsonFile(requireFlag(flags, 'brief')));
1717
2311
  printJson(result);
@@ -1736,22 +2330,45 @@ async function main() {
1736
2330
  if (selectTestCmd !== undefined)
1737
2331
  assertSelectableTestCmd(selectTestCmd);
1738
2332
  const tasks = readJsonFile(tasksFile);
1739
- // D-46/P9-29: the queue is the only component that knows a branch landed,
1740
- // and it used to say so to stdout and nowhere else — which is why the
1741
- // projector's `completed` column was unreachable by machine. `--session`
1742
- // is what turns the run into a fact; without it the queue still runs, and
1743
- // still tells nobody.
1744
- const events = flags.session
1745
- ? { ctx: eventContextFromFlags(flags), ...eventOptsFromFlags(flags) }
1746
- : 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) };
1747
2344
  // `--tasks` is hand-written, so its ids are whatever was typed. Minting
1748
2345
  // them from the plan before any git runs is the whole point of P9-29: a
1749
2346
  // bare id here would put `wave-merged` in the log under a spelling the
1750
2347
  // plan never used, which is how the dogfood epic folded one task instead
1751
2348
  // of six. Refuse the run whole rather than merge some and mislabel them.
1752
- if (events && !flags.plan) {
2349
+ if (!flags.plan) {
1753
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 });
1754
2351
  }
2352
+ // --batch (roadmap merge-lanes) groups tasks with groupForBatch before
2353
+ // any of them run, and that grouping needs two things `--tasks` alone
2354
+ // does not carry: the plan's `edges`, so a task never stacks with
2355
+ // something it depends_on, and — for a tasks-file entry that omits its
2356
+ // own `claims` — the plan's claim list, so a hand-typed batch run is not
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
+ //
2361
+ // `attemptCandidate` runs the epic's test command directly against the
2362
+ // whole batch candidate — there is no per-task file set to narrow it to,
2363
+ // so a `--select-test-cmd` template would either render nonsensically or
2364
+ // silently run the full suite while the outcome claimed it was selected
2365
+ // (the same lie D-260 refuses above). Refuse the combination instead of
2366
+ // ignoring the flag.
2367
+ if (flags.batch === 'true' && selectTestCmd !== undefined) {
2368
+ throw new SmithError('cli.incompatible-flags', 'queue run --batch does not support --select-test-cmd: a batch candidate has no single task to narrow the test command to.', { epic });
2369
+ }
2370
+ let edges = [];
2371
+ let claimsById = new Map();
1755
2372
  if (flags.plan) {
1756
2373
  const plan = readJsonFile(flags.plan);
1757
2374
  // …and the plan is not the only register: a follow-up minted by
@@ -1763,6 +2380,11 @@ async function main() {
1763
2380
  const loggedIds = logged.map((t) => t.taskId);
1764
2381
  for (const task of tasks)
1765
2382
  task.taskId = resolveTaskId(plan, task.taskId, loggedIds);
2383
+ edges = plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn }));
2384
+ claimsById = new Map(plan.tasks.map((t) => [
2385
+ t.task_id,
2386
+ Array.isArray(t.claims) ? t.claims.filter((c) => typeof c === 'string') : [],
2387
+ ]));
1766
2388
  // D-186: the ids are the plan's now, so the order can be too. `--tasks`
1767
2389
  // is hand-written, and merging in the order someone typed lets a task
1768
2390
  // land before the task it declares `depends_on` — the epic's cumulative
@@ -1770,21 +2392,81 @@ async function main() {
1770
2392
  // and blames the wrong branch when it goes red. `admit()` exists for
1771
2393
  // exactly this and had no caller until here; a follow-up the plan never
1772
2394
  // declared carries no edges, so it just sorts by id among its peers.
1773
- const order = admit(tasks.map((t) => ({ task_id: t.taskId })), plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn })));
2395
+ const order = admit(tasks.map((t) => ({ task_id: t.taskId })), edges);
1774
2396
  // Stable: two tasks the plan does not order keep the order admit gave
1775
2397
  // them, and a duplicated id is still run twice rather than dropped.
1776
2398
  tasks.sort((a, b) => order.indexOf(a.taskId) - order.indexOf(b.taskId));
1777
2399
  }
2400
+ const stepOpts = {
2401
+ projectDir,
2402
+ epic,
2403
+ testCmd,
2404
+ ...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
2405
+ ...(events ? { events } : {}),
2406
+ };
1778
2407
  const outcomes = [];
1779
2408
  let allMerged = true;
2409
+ if (flags.batch === 'true') {
2410
+ const byId = new Map(tasks.map((t) => [t.taskId, t]));
2411
+ // A tasks-file entry's own `claims` wins over the plan's — the plan
2412
+ // describes what the task was authored to touch, the tasks file is
2413
+ // what actually ran, and the two can disagree the same way a diff can
2414
+ // outgrow the claim it was cut from.
2415
+ const groupable = tasks.map((t) => ({
2416
+ taskId: t.taskId,
2417
+ claims: t.claims ?? claimsById.get(t.taskId),
2418
+ }));
2419
+ const groups = groupForBatch(groupable, edges, loadWorktreePolicy().serializeAlwaysGlobs);
2420
+ const batches = [];
2421
+ for (const group of groups) {
2422
+ const groupTasks = group.map((id) => {
2423
+ const t = byId.get(id);
2424
+ if (!t) {
2425
+ // groupForBatch only ever returns ids it was handed, so this
2426
+ // would mean the group and --tasks have drifted apart — a wrong
2427
+ // candidate is worse than a refusal.
2428
+ throw new SmithError('queue.batch-unknown-task', `--batch grouped task "${id}" but no task by that id was in --tasks.`, { epic, taskId: id });
2429
+ }
2430
+ return t;
2431
+ });
2432
+ // groupForBatch's own contract (see its doc comment): a group of one
2433
+ // is not a batch, it's a task that closed the open group before it —
2434
+ // no candidate to stack, so it runs through the plain step it would
2435
+ // have taken without --batch at all, rather than through batchStep's
2436
+ // fold/candidate-worktree machinery for a "stack" of one.
2437
+ if (groupTasks.length === 1) {
2438
+ const [only] = groupTasks;
2439
+ const outcome = await step(only, stepOpts);
2440
+ outcomes.push(outcome);
2441
+ const landed = outcome.outcome === 'merged';
2442
+ // `step` refuses before any test runs on `nothing-to-merge` (D-30's
2443
+ // uncommitted-work guard) and `rebase-conflict` — 0 suites ran, not
2444
+ // 1, for either. Every other outcome it can return (`merged`,
2445
+ // `tests-failed`, `integration-dirty`) only happens after
2446
+ // `runTestCmd`.
2447
+ const suiteRuns = outcome.outcome === 'nothing-to-merge' || outcome.outcome === 'rebase-conflict' ? 0 : 1;
2448
+ batches.push({ task_ids: group, suite_runs: suiteRuns, landed });
2449
+ if (!landed) {
2450
+ allMerged = false;
2451
+ break;
2452
+ }
2453
+ }
2454
+ else {
2455
+ const result = await batchStep(groupTasks, stepOpts);
2456
+ outcomes.push(...result.outcomes);
2457
+ const landed = result.outcomes.every((o) => o.outcome === 'merged');
2458
+ batches.push({ task_ids: group, suite_runs: result.suiteRuns, landed });
2459
+ if (!landed) {
2460
+ allMerged = false;
2461
+ break;
2462
+ }
2463
+ }
2464
+ }
2465
+ printJson({ outcomes, batches });
2466
+ return allMerged ? 0 : 1;
2467
+ }
1780
2468
  for (const task of tasks) {
1781
- const outcome = await step(task, {
1782
- projectDir,
1783
- epic,
1784
- testCmd,
1785
- ...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
1786
- ...(events ? { events } : {}),
1787
- });
2469
+ const outcome = await step(task, stepOpts);
1788
2470
  outcomes.push(outcome);
1789
2471
  if (outcome.outcome !== 'merged') {
1790
2472
  allMerged = false;
@@ -1813,7 +2495,10 @@ async function main() {
1813
2495
  // the merge — and adopt any task with it, which is the forgery the whole
1814
2496
  // verb exists to prevent. Deriving it from the plan-resolved id means the
1815
2497
  // branch and the id it is logged under cannot disagree.
1816
- { taskId, branch: taskBranchName(plan.epic_id, taskId) }, {
2498
+ {
2499
+ taskId,
2500
+ branch: taskBranchName(plan.epic_id, taskId, epicBranchPrefix(projectDir, plan.epic_id)),
2501
+ }, {
1817
2502
  projectDir,
1818
2503
  epic: plan.epic_id,
1819
2504
  mergeCommit,
@@ -1831,6 +2516,7 @@ async function main() {
1831
2516
  ...eventOptsFromFlags(flags),
1832
2517
  ...(flags.actor === undefined ? {} : { actor: flags.actor }),
1833
2518
  ...(flags.continues === undefined ? {} : { continues: flags.continues }),
2519
+ ...(flags.prompt === undefined ? {} : { promptId: flags.prompt }),
1834
2520
  }));
1835
2521
  return 0;
1836
2522
  }
@@ -1894,7 +2580,7 @@ async function main() {
1894
2580
  process.stderr.write(`warning: this session-start is ${result.event_id}, not ${result.record.session_id}#0 — ` +
1895
2581
  `session "${result.record.session_id}" was already open. The record is written and ` +
1896
2582
  'durable, but a log has one root and readers take the first, so nothing will read this ' +
1897
- '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 ' +
1898
2584
  'event that is already there.\n');
1899
2585
  }
1900
2586
  printJson({ ...result, on_timeline: onTimeline });
@@ -2013,6 +2699,27 @@ async function main() {
2013
2699
  printJson(report);
2014
2700
  return report.ok ? 0 : 1;
2015
2701
  }
2702
+ if (namespace === 'dispatch' && action === 'lint') {
2703
+ // dispatch.md "Carry into the prompt" / "Declare each judge's artifact":
2704
+ // catches a stated turn budget that disagrees with the template's
2705
+ // `maxTurns` in either direction, a template it cannot read at all, and a
2706
+ // judge prompt missing (or mismatching) its declared-artifact line,
2707
+ // before the agent ever runs. Fails closed: any applicable status but
2708
+ // `ok` exits 1. See dispatchLint.ts for why each status means what it
2709
+ // means. `-` reads stdin, same convention as `prompt wrap`/`prompt record`.
2710
+ const [file] = requirePositionals(positional, usageFor('dispatch lint'), 1);
2711
+ const prompt = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
2712
+ const report = await lintDispatchPrompt({
2713
+ prompt,
2714
+ role: requireFlag(flags, 'role'),
2715
+ taskId: requireFlag(flags, 'task'),
2716
+ sessionId: requireFlag(flags, 'session'),
2717
+ ...(flags['agents-dir'] ? { agentsDir: flags['agents-dir'] } : {}),
2718
+ eventOpts: eventOptsFromFlags(flags),
2719
+ });
2720
+ printJson(report);
2721
+ return report.exitCode;
2722
+ }
2016
2723
  if (namespace === 'tester' && action === 'check') {
2017
2724
  // The other half of `dispatch check`: that one asks whether the critic ran
2018
2725
  // on the finder's model, this one asks whether a tester ran at all, in a
@@ -2113,7 +2820,7 @@ async function main() {
2113
2820
  // Same refusal as `wave check`, for the same reason: validating the
2114
2821
  // empty set answers "admissible" about a wave nobody described.
2115
2822
  if (positional.length === 0) {
2116
- 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 });
2117
2824
  }
2118
2825
  const recordsById = new Map(plan.tasks.map((task) => [task.task_id, task]));
2119
2826
  // Claims and keeps_exports are read through the one door validateWave
@@ -2179,20 +2886,20 @@ async function main() {
2179
2886
  }
2180
2887
  if (namespace === 'issues' && action === 'report') {
2181
2888
  // The one path in this epic that runs `gh` for real.
2182
- const { events, isEnabled, register, eventOpts } = await issueInputs(flags);
2889
+ const { events, isEnabled, register, eventOpts, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
2183
2890
  const clock = () => new Date().toISOString();
2184
- printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts));
2891
+ printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts, resolveProject, selfFallback, lineageEvents));
2185
2892
  return 0;
2186
2893
  }
2187
2894
  if (namespace === 'issues' && action === 'preview') {
2188
2895
  // Same inputs, no `gh` and no event: the runner throws if anything
2189
2896
  // reaches it, so a preview that spawned would fail loudly, not quietly.
2190
- const { events, isEnabled, register } = await issueInputs(flags);
2897
+ const { events, isEnabled, register, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
2191
2898
  const neverRun = (cmd) => {
2192
2899
  throw new Error(`issues preview must never run a command, asked for ${cmd}`);
2193
2900
  };
2194
2901
  const clock = () => new Date().toISOString();
2195
- printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock));
2902
+ printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock, resolveProject, selfFallback, lineageEvents));
2196
2903
  return 0;
2197
2904
  }
2198
2905
  // The judge sandbox (A3). `worktree fingerprint`/`verify` above detect a
@@ -2281,7 +2988,7 @@ async function main() {
2281
2988
  worktree: flags.worktree ?? null,
2282
2989
  ...(tier !== undefined ? { tier } : {}),
2283
2990
  ...(flags.schema !== undefined ? { schema: flags.schema } : {}),
2284
- }, { policy: loadHarnessPolicy(flags.policy) });
2991
+ }, { policy: loadHarnessPolicy(flags.policy), planOpts: planOptsFromFlags(flags) });
2285
2992
  printJson(invocation);
2286
2993
  return 0;
2287
2994
  }
@@ -2367,7 +3074,15 @@ async function main() {
2367
3074
  const { runGate } = await import('./gate.js');
2368
3075
  const [taskId] = requirePositionals(positional, usageFor('gate run'));
2369
3076
  const worktreeDir = requireFlag(flags, 'worktree');
2370
- const checks = readJsonFile(requireFlag(flags, 'checks'));
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);
3085
+ const checks = readChecksFile(requireFlag(flags, 'checks'));
2371
3086
  // The result file has the same two intake shapes as findings below, and for
2372
3087
  // the same reason. With `--agent`, `--result` is the worker's half —
2373
3088
  // run_status/structured_output/artifacts — and the dispatcher stamps the
@@ -2375,6 +3090,13 @@ async function main() {
2375
3090
  // meter, so a token count it writes is invented (D-18/P9-17). Without
2376
3091
  // `--agent` the file is taken as a complete document, which is what a
2377
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.
2378
3100
  const resultFile = readJsonFile(requireFlag(flags, 'result'));
2379
3101
  const result = flags.agent
2380
3102
  ? stampResultEnvelope(resultFile, {
@@ -2382,8 +3104,8 @@ async function main() {
2382
3104
  agent: flags.agent,
2383
3105
  provider: requireFlag(flags, 'provider'),
2384
3106
  modelTier: requireFlag(flags, 'model-tier'),
2385
- inputTokens: requireIntFlag(flags, 'input-tokens'),
2386
- outputTokens: requireIntFlag(flags, 'output-tokens'),
3107
+ inputTokens: boundedIntFlag(flags, 'input-tokens', { min: 0 }),
3108
+ outputTokens: boundedIntFlag(flags, 'output-tokens', { min: 0 }),
2387
3109
  })
2388
3110
  : resultFile;
2389
3111
  // Two intake shapes. `--evidence` is what judges actually produce
@@ -2392,8 +3114,9 @@ async function main() {
2392
3114
  // judge at this gate (D-32/P9-13). `--findings` stays for already-minted
2393
3115
  // records (replays, fixtures, cross-check re-runs).
2394
3116
  const findingsInput = [
2395
- ...(flags.findings ? readJsonFile(flags.findings) : []),
3117
+ ...(flags.findings ? readFindingsInputFile(flags.findings) : []),
2396
3118
  ...mintFromEvidence(args, taskId),
3119
+ ...(flags['uiux-visual'] ? mintFromUiuxVisual(flags['uiux-visual'], taskId) : []),
2397
3120
  ];
2398
3121
  const lessons = flags.lessons ? parseLessons(readFileSync(flags.lessons, 'utf8')) : [];
2399
3122
  const ctx = eventContextFromFlags(flags);
@@ -2428,18 +3151,24 @@ async function main() {
2428
3151
  // the shape the grader's turn stayed open with its verdict on the command
2429
3152
  // line, and the gate blocked on the judge it was about to read.
2430
3153
  const evidenceGiven = evidenceSources(args);
2431
- const graderGiven = flags.grader ? [{ foundBy: 'grader', file: flags.grader }] : [];
2432
- 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];
2433
3161
  if (handedIn.length > 0) {
2434
3162
  const turns = await readJudgeTurns(taskId, ctx, eventOptsFromFlags(flags));
2435
3163
  const closed = new Set();
2436
- for (const { foundBy, file } of handedIn) {
2437
- if (closed.has(foundBy))
3164
+ for (const { foundBy, file, kind } of handedIn) {
3165
+ const closeKey = kind ? `${foundBy}:${kind}` : foundBy;
3166
+ if (closed.has(closeKey))
2438
3167
  continue;
2439
- if (!turns.some((t) => t.role === foundBy && !t.reported))
3168
+ if (!turns.some((t) => t.role === foundBy && t.kind === (kind ?? null) && !t.reported))
2440
3169
  continue;
2441
- closed.add(foundBy);
2442
- 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));
2443
3172
  }
2444
3173
  }
2445
3174
  // P9-11: the genuinely clean case, said out loud. A judge that found
@@ -2452,7 +3181,7 @@ async function main() {
2452
3181
  // role called "true" would close nothing (no such dispatch exists) and
2453
3182
  // say so nowhere, so a bare --no-findings is a usage error instead.
2454
3183
  if (role === 'true') {
2455
- 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>' });
2456
3185
  }
2457
3186
  await recordJudgeReport({ taskId, role, noFindings: true }, ctx, eventOptsFromFlags(flags));
2458
3187
  }
@@ -2481,10 +3210,75 @@ async function main() {
2481
3210
  ...(graderVerdict !== undefined ? { graderVerdict } : {}),
2482
3211
  ...(budget ? { budget } : {}),
2483
3212
  ...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
3213
+ uiAffecting: uiAffectingFromFlags(flags, taskId),
3214
+ crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir }) },
2484
3215
  }, ctx, eventOptsFromFlags(flags));
2485
3216
  printJson(outcome);
2486
3217
  return outcome.outcome === 'blocked' ? 1 : 0;
2487
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 } : {}),
3253
+ }, ctx, eventOptsFromFlags(flags));
3254
+ printJson(outcome);
3255
+ return outcome.outcome === 'blocked' ? 1 : 0;
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
+ }
2488
3282
  // D-40/P9-25: the gate's coverage evidence, without staging a gate run.
2489
3283
  // This is the verb the D-40 investigation wanted and did not have — it took
2490
3284
  // a coverage re-run on the pre-task-4 integration branch to establish what
@@ -2531,11 +3325,21 @@ async function main() {
2531
3325
  // session reports half an epic's cost as the whole of it, and reports it
2532
3326
  // under an alarm threshold it may already have crossed.
2533
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
+ });
2534
3335
  const report = checkBudgetAlarm(events, loadBudgetPolicy(flags.policy), {
2535
3336
  sessionId,
2536
3337
  ...(flags.epic ? { epicId: flags.epic } : {}),
3338
+ policyForEpic,
2537
3339
  });
2538
- 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
+ ];
2539
3343
  printJson(envOverrides.length > 0 ? { ...report, budgetEnvOverrides: envOverrides } : report);
2540
3344
  return report.ok ? 0 : 1;
2541
3345
  }
@@ -2564,7 +3368,7 @@ async function main() {
2564
3368
  if (namespace === 'integration' && action === 'check') {
2565
3369
  const epicId = requireFlag(flags, 'epic');
2566
3370
  const projectDir = requireFlag(flags, 'project');
2567
- const checks = readJsonFile(requireFlag(flags, 'checks'));
3371
+ const checks = readChecksFile(requireFlag(flags, 'checks'));
2568
3372
  const ctx = eventContextFromFlags(flags);
2569
3373
  const record = await runIntegrationCheck({
2570
3374
  epicId,
@@ -2588,12 +3392,26 @@ async function main() {
2588
3392
  const outcome = await runEpicVerdict({
2589
3393
  epicId,
2590
3394
  integrationHeadSha: integrationHeadSha(projectDir, epicId),
3395
+ integrationBranch: integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)),
2591
3396
  mcp: mcpSurfaceFor(epicId, projectDir, flags),
2592
3397
  goal: epicGoalFor(epicId, flags),
2593
3398
  // D-126: the live plan is a voter. Without this the roster is the
2594
3399
  // event-log fold alone, and a task an amendment added but nobody
2595
3400
  // dispatched is invisible rather than unfinished.
2596
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 }) },
2597
3415
  }, ctx, eventOptsFromFlags(flags));
2598
3416
  printJson(outcome);
2599
3417
  return outcome.outcome === 'hold' ? 1 : 0;
@@ -2610,9 +3428,17 @@ async function main() {
2610
3428
  const record = await closeEpic({
2611
3429
  epicId,
2612
3430
  integrationHeadSha: integrationHeadSha(projectDir, epicId),
3431
+ integrationBranch: integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)),
2613
3432
  mcp: mcpSurfaceFor(epicId, projectDir, flags),
2614
3433
  goal: epicGoalFor(epicId, flags),
2615
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 }) },
2616
3442
  ...(flags['override-rationale'] !== undefined
2617
3443
  ? { overrideRationale: flags['override-rationale'] }
2618
3444
  : {}),
@@ -2631,13 +3457,26 @@ async function main() {
2631
3457
  const plan = readJsonFile(requireFlag(flags, 'plan'));
2632
3458
  // Evidence, not identity (interview N-2): the reviewer reports what it
2633
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
+ }
2634
3472
  const evidence = flags.evidence ? readJsonFile(flags.evidence) : [];
2635
3473
  // No branch, no review. The whole point of this dispatch is that it reads
2636
3474
  // the code that now exists; recording one against a head that could not be
2637
3475
  // read would produce a review nothing can be shown to cover.
2638
3476
  const headSha = integrationHeadSha(projectDir, epicId);
2639
3477
  if (headSha === null) {
2640
- 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 });
2641
3480
  }
2642
3481
  const record = await recordSpecReview({
2643
3482
  epicId,
@@ -2648,6 +3487,7 @@ async function main() {
2648
3487
  ? { reviewedByProvider: flags['reviewed-by-provider'] }
2649
3488
  : {}),
2650
3489
  evidence,
3490
+ ...(noFindings ? { noFindings: true } : {}),
2651
3491
  }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
2652
3492
  printJson(record);
2653
3493
  // Exit 0 even when it found something: the review ran, and a spec finding
@@ -2765,7 +3605,7 @@ async function main() {
2765
3605
  // are indistinguishable to everything downstream.
2766
3606
  const mintTaskId = specDispatch && plan ? `${plan.epic_id}/${RESERVED_TASK_ID}` : defaultTaskId;
2767
3607
  const findingsInput = [
2768
- ...(flags.findings ? readJsonFile(flags.findings) : []),
3608
+ ...(flags.findings ? readFindingsInputFile(flags.findings) : []),
2769
3609
  // The mint id, not the default one: a spec finding belongs to the epic
2770
3610
  // (D-33), and the dispatch's scope rides along with it so every finding
2771
3611
  // in the batch is spec-scoped by construction.
@@ -2803,7 +3643,9 @@ async function main() {
2803
3643
  // can hold the fix, because the fix is a plan amendment.
2804
3644
  const isSpec = !result.suppressed && findingScope(result.finding) === SPEC_FINDING_SCOPE;
2805
3645
  if (!result.suppressed && !isSpec) {
2806
- 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);
2807
3649
  }
2808
3650
  raised.push({
2809
3651
  findingId: result.suppressed ? null : result.finding.finding_id,
@@ -2812,6 +3654,8 @@ async function main() {
2812
3654
  attribution: isSpec ? SPEC_FINDING_SCOPE : routing.attribution,
2813
3655
  suppressed: result.suppressed,
2814
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,
2815
3659
  });
2816
3660
  }
2817
3661
  printJson(raised);
@@ -2851,11 +3695,11 @@ async function main() {
2851
3695
  // Refusing here, on the status alone, keeps argument order as the reading
2852
3696
  // order and lets the error name the verb that CAN take the edge.
2853
3697
  const AMENDMENT_ROUTES = {
2854
- [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',
2855
- [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',
2856
3700
  };
2857
3701
  if (newStatus in AMENDMENT_ROUTES) {
2858
- 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 });
2859
3703
  }
2860
3704
  const ctx = eventContextFromFlags(flags);
2861
3705
  const finding = await transitionFinding(findingId, newStatus, ctx, eventOptsFromFlags(flags));
@@ -2864,20 +3708,25 @@ async function main() {
2864
3708
  }
2865
3709
  // The dispatch-time half of P9-15, shaped exactly like `lessons
2866
3710
  // for-dispatch`: the caller composing a task prompt asks for the block and
2867
- // splices it. `--plan` is required rather than optional here — without it
2868
- // the claims list is empty, every finding fails the join, and the command
2869
- // 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.
2870
3716
  if (namespace === 'findings' && action === 'for-dispatch') {
2871
- const usage = 'smith findings for-dispatch --session ... --plan plan.json --task task-id [--state-dir dir]';
2872
- requireFlag(flags, 'plan');
2873
- const taskId = requireFlag(flags, 'task');
3717
+ const usage = usageLine(usageFor('findings for-dispatch'));
3718
+ if (flags.task !== undefined)
3719
+ requireFlag(flags, 'plan');
2874
3720
  const sessionId = requireFlag(flags, 'session');
2875
3721
  const eventOpts = eventOptsFromFlags(flags);
2876
3722
  requireSession(sessionId, eventOpts);
2877
3723
  if (positional.length > 0) {
2878
3724
  throw new SmithError('cli.usage', `Unexpected argument. Usage: ${usage}`, { positional });
2879
3725
  }
2880
- 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));
2881
3730
  return 0;
2882
3731
  }
2883
3732
  // Re-dating a finding's evidence (P9-15). Deliberately its own verb and not
@@ -2889,8 +3738,8 @@ async function main() {
2889
3738
  const ctx = eventContextFromFlags(flags);
2890
3739
  const eventOpts = eventOptsFromFlags(flags);
2891
3740
  requireSession(ctx.sessionId, eventOpts);
2892
- await reverifyFinding(findingId, flags.note ?? '', ctx, eventOpts);
2893
- 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 });
2894
3743
  return 0;
2895
3744
  }
2896
3745
  // D-21 Part 4. Corrects a malformed amends_task_ids entry on a finding
@@ -2930,18 +3779,34 @@ async function main() {
2930
3779
  printJson(pending);
2931
3780
  return 0;
2932
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
+ }
2933
3789
  if (namespace === 'waivers' && action === 'apply') {
2934
3790
  const [decisionsFile] = requirePositionals(positional, usageFor('waivers apply'));
2935
3791
  const decisions = readJsonFile(decisionsFile);
2936
3792
  const ctx = eventContextFromFlags(flags);
2937
- const results = await applyBatch(decisions, ctx, eventOptsFromFlags(flags));
2938
- 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);
2939
3798
  return 0;
2940
3799
  }
2941
3800
  if (namespace === 'lessons' && action === 'candidates') {
2942
3801
  const { openDb } = await import('./db/projector.js');
2943
3802
  const { lessonsPage } = await import('./db/queries.js');
2944
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));
2945
3810
  const handle = openDb(dbPath);
2946
3811
  try {
2947
3812
  const scope = flags.session ? { sessionId: flags.session } : {};
@@ -3016,7 +3881,7 @@ async function main() {
3016
3881
  if (namespace === 'lessons' && action === 'compile') {
3017
3882
  const { openDb } = await import('./db/projector.js');
3018
3883
  const { lessonsPage } = await import('./db/queries.js');
3019
- const { compileLessons } = await import('./lessons.js');
3884
+ const { compileLessons, lessonsToPreserve } = await import('./lessons.js');
3020
3885
  const dbPath = flags.db ?? STATE_DB_PATH;
3021
3886
  // The operator's copy, not the package's: under an install the shipped one
3022
3887
  // lives in node_modules, which the next `npm i` replaces wholesale. mkdir
@@ -3029,7 +3894,14 @@ async function main() {
3029
3894
  try {
3030
3895
  const scope = flags.session ? { sessionId: flags.session } : {};
3031
3896
  const approved = lessonsPage(handle.db, scope).approved;
3032
- 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) => ({
3033
3905
  lessonId: l.lessonId,
3034
3906
  lessonScope: l.lessonScope,
3035
3907
  statement: l.statement,
@@ -3037,9 +3909,14 @@ async function main() {
3037
3909
  claimPath: l.claimPath,
3038
3910
  agentRole: l.agentRole,
3039
3911
  caseType: l.caseType,
3040
- })));
3912
+ }));
3913
+ const markdown = compileLessons(compileInputs, existingMarkdown);
3041
3914
  writeFileSync(outPath, markdown, 'utf8');
3042
- 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 });
3043
3920
  return 0;
3044
3921
  }
3045
3922
  finally {
@@ -3119,8 +3996,14 @@ async function main() {
3119
3996
  model: requireFlag(flags, 'model'),
3120
3997
  ...(flags.provider ? { provider: flags.provider } : {}),
3121
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 } : {}),
3122
4002
  }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
3123
- printJson(stored);
4003
+ printJson({
4004
+ ...stored,
4005
+ expected_line: formatDeclaredArtifactLine(requireFlag(flags, 'artifact')),
4006
+ });
3124
4007
  return 0;
3125
4008
  }
3126
4009
  if (namespace === 'judge' && action === 'report') {
@@ -3138,16 +4021,27 @@ async function main() {
3138
4021
  // Here the role is already named by --role, so the valueless spelling
3139
4022
  // is the right one and 'true' means what it says.
3140
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 } : {}),
3141
4026
  }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
3142
4027
  printJson(report);
3143
4028
  return 0;
3144
4029
  }
3145
4030
  if (namespace === 'judge' && action === 'outstanding') {
3146
4031
  const sessionId = requireFlag(flags, 'session');
4032
+ const taskId = requireFlag(flags, 'task');
3147
4033
  const eventOpts = eventOptsFromFlags(flags);
3148
4034
  requireSession(sessionId, eventOpts);
3149
- const open = outstandingJudges(await readJudgeTurns(requireFlag(flags, 'task'), { sessionId }, eventOpts));
3150
- 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 });
3151
4045
  return open.length > 0 ? 1 : 0;
3152
4046
  }
3153
4047
  if (namespace === 'judge' && action === 'escalations') {
@@ -3172,7 +4066,7 @@ async function main() {
3172
4066
  // Deliberately ahead of `judge run` in this file for the same reason it is
3173
4067
  // ahead of it in the runbook: an operator reaching for a calibration call
3174
4068
  // to find out why a provider keeps failing usually needed this instead.
3175
- const report = judgePreflight(flags.policy);
4069
+ const report = judgePreflight(flags.policy, { projectDir: flags.project });
3176
4070
  printJson(report);
3177
4071
  return report.problems.length > 0 ? 1 : 0;
3178
4072
  }
@@ -3192,7 +4086,15 @@ async function main() {
3192
4086
  // the split between them is the operator mandate made operable: `request`
3193
4087
  // shows exactly what would leave the machine WITHOUT sending it, `run`
3194
4088
  // sends it, and `reconcile` needs no provider at all.
3195
- 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) });
3196
4098
  if (action === 'request') {
3197
4099
  // Read-only and network-free by construction: it builds the JudgeRequest
3198
4100
  // and prints it. Every refusal independentFinderRequest() can raise —
@@ -3299,7 +4201,7 @@ async function main() {
3299
4201
  // missing feature but a silent data swap: the read path re-projects each
3300
4202
  // session on the first request (app.ts's createRefresher), and apply()
3301
4203
  // rebuilds the whole milestones table from a roadmap file while it is
3302
- // there. Unset, that file falls back to black-smith's own
4204
+ // there. Unset, that file falls back to blacksmith's own
3303
4205
  // factory/specs/roadmap.md — so serving another project's db showed this
3304
4206
  // repo's roadmap from the first page load onward.
3305
4207
  const roadmapPath = flags['roadmap-path'];
@@ -3307,13 +4209,30 @@ async function main() {
3307
4209
  // request, and the plan files it consults to place an unstamped epic
3308
4210
  // (D-246) must be the served db's, not this repo's.
3309
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));
3310
4225
  mod.serve({
3311
4226
  port,
3312
4227
  dbPath,
4228
+ ...(stores.length > 0 ? { stores } : {}),
3313
4229
  ...(stateDir ? { stateDir } : {}),
3314
4230
  ...(roadmapPath ? { roadmapPath } : {}),
3315
4231
  ...(specsDir ? { specsDir } : {}),
3316
4232
  ...(nowIso !== undefined ? { nowIso } : {}),
4233
+ claudeConfigDir: claude.dir,
4234
+ claudeConfigSource: claude.source,
4235
+ knownRoots: [...new Set([REPO_ROOT, WORK_ROOT])],
3317
4236
  });
3318
4237
  return 0;
3319
4238
  }
@@ -3418,11 +4337,63 @@ async function main() {
3418
4337
  });
3419
4338
  return 1;
3420
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
+ });
3421
4390
  main()
3422
4391
  .then((code) => {
4392
+ mainExitCode = code;
3423
4393
  process.exitCode = code;
3424
4394
  })
3425
4395
  .catch((err) => {
4396
+ mainExitCode = 1;
3426
4397
  // A SmithError is a designed answer: its code names the failure and its
3427
4398
  // details name the record, so a stack would only add noise. Anything else
3428
4399
  // is a bug, and D-135 is what that costs — `{"message":"Cannot read