@juzser/blacksmith 0.1.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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,3185 @@
1
+ #!/usr/bin/env node
2
+ // Thin CLI router: one subcommand per orchestrator module operation. No
3
+ // framework — plain argv parsing. Logic lives in the modules; this file only
4
+ // wires stdin/argv to them and prints JSON.
5
+ //
6
+ // One rule about the import list below: **nothing that reaches the database
7
+ // layer is imported at module scope.** `db/schema.js` pulls in drizzle-orm,
8
+ // which roughly triples what booting this file costs, and a static import here
9
+ // charges that to every invocation of the binary — `smith --help` and `smith
10
+ // policy check` as much as `smith stats overview`. The nine modules that reach
11
+ // it (`attribution`, `daemon`, `db/projector`, `db/queries`, `epic`, `gate`,
12
+ // `lessonAudit`, `lessons`, `scheduler`) are `await import()`ed inside the
13
+ // branches that use them, which is why `main()` is async. Type-only imports of
14
+ // the same modules are fine and stay up here: tsc erases them, so they cost
15
+ // nothing at runtime. `test/cliBoot.test.ts` reads the built graph and fails if
16
+ // the database layer creeps back into it.
17
+ import { spawn } from 'node:child_process';
18
+ import { mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { pathToFileURL } from 'node:url';
21
+ import { parseArgs } from './args.js';
22
+ import { closeAudit, consolidateAudit, cutAudit, decideAudit, openAudit, recordAudit, resolveAudit, } from './audit.js';
23
+ import { checkBudgetAlarm } from './budgetAlarm.js';
24
+ import { loadBudgetPolicy } from './budgets.js';
25
+ import { collectCommittedChanges, loadWorktreePolicy, postRunCheck, validateWave, writeRootCheck, } from './claims.js';
26
+ import { collectCoverageEvidence } from './coverage.js';
27
+ import { loadCrosscheckPolicy } from './crosscheck.js';
28
+ import { independentFinderRequest, reconcile, runIndependentFinder, } from './crossFinding.js';
29
+ import { checkDelegationGrants, checkDelegationLog, loadDelegationPolicy } from './delegation.js';
30
+ import { checkDispatchAsymmetry } from './dispatchAudit.js';
31
+ import { loadDotEnv } from './dotenv.js';
32
+ import { loadEffortPolicy, resolveEffort } from './effort.js';
33
+ import { SmithError } from './errors.js';
34
+ import { checkEscalationLadder } from './escalation.js';
35
+ import { appendEvent, filterEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
36
+ import { findingsForDispatch } from './findingContext.js';
37
+ import { AMEND_PENDING_STATUS, AMENDED_STATUS, findingScope, listFindings, mintFindings, OPEN_FINDING_STATUSES, raiseFinding, repairObligation, reverifyFinding, SPEC_FINDING_SCOPE, transition as transitionFinding, } from './findings.js';
38
+ import { recordGoalCheck, resolveEpicGoal } from './goalCheck.js';
39
+ import { decideHookPayload } from './hookDecision.js';
40
+ import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
41
+ import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
42
+ import { integrationHeadSha, runIntegrationCheck } from './integration.js';
43
+ import { judgePreflight } from './judgePreflight.js';
44
+ import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
45
+ import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
46
+ import { DOTENV_PATH, LESSONS_MD_PATH, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
47
+ import { diffPlans, livePlanTasks, resolveTaskId, validatePlan, } from './plan.js';
48
+ import { runPlanQuorum } from './planQuorum.js';
49
+ import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
50
+ import { recordUserPrompt } from './prompts.js';
51
+ import { checkBrief, wrapIngested } from './provenance.js';
52
+ import { runJudge } from './providers/index.js';
53
+ import { admit, adopt, step } from './queue.js';
54
+ import { stampResultEnvelope } from './results.js';
55
+ import { checkRuntime } from './runtime.js';
56
+ import { checkSameMistakeKpi } from './sameMistakeKpi.js';
57
+ import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './sandbox.js';
58
+ import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
59
+ import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
60
+ import { parseLessons } from './severity.js';
61
+ import { amendPlan, recordSpecReview } from './spec.js';
62
+ import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
63
+ import { checkStack, loadStackAnswers } from './stack.js';
64
+ import { buildSymbolGraph, collectSources } from './symbols.js';
65
+ import { emitEdgesRecorded, emitTasksAdded, emitWaveAdmitted, readAddedTasks, } from './taskEvents.js';
66
+ import { loadTaxonomy } from './taxonomy.js';
67
+ import { checkTesterIsolation } from './testerAudit.js';
68
+ import { assertSelectableTestCmd } from './testSelect.js';
69
+ import { COMMANDS, flagSpecFor, helpText, isDocumented, positionalNames, usageFor, usageLine, usageText, } from './usage.js';
70
+ import { applyBatch, pendingBatch } from './waivers.js';
71
+ import { blocksAdmission, checkWaveBudget, } from './waveBudget.js';
72
+ import { computeNextWave, liveWaveTasks } from './waveNext.js';
73
+ import { createTaskWorktree, listStale, RESERVED_TASK_ID, removeTaskWorktree, taskBranchName, } from './worktree.js';
74
+ function printJson(value) {
75
+ process.stdout.write(`${JSON.stringify(value)}\n`);
76
+ }
77
+ function readJsonFile(filePath) {
78
+ return JSON.parse(readFileSync(filePath, 'utf8'));
79
+ }
80
+ /** A task spec's `budget.tokens` when it is a usable positive number, else null. */
81
+ function declaredTokens(budget) {
82
+ if (typeof budget !== 'object' || budget === null)
83
+ return null;
84
+ const tokens = budget.tokens;
85
+ return typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0 ? tokens : null;
86
+ }
87
+ /**
88
+ * What this wave will cost, task by task, priced from what declared it.
89
+ *
90
+ * Plan-declared tasks are priced from livePlanTasks, never `plan.tasks`
91
+ * (D-126/D-184): a superseded spec lives on in the array under the same
92
+ * `task_id`, so pricing off the raw list would read whichever copy came first
93
+ * rather than the one that is in force.
94
+ *
95
+ * Two different silences, and they get two different answers.
96
+ *
97
+ * A task the plan declares but leaves unpriced comes back `null` — not 0,
98
+ * because those are opposite claims (0 says "this costs nothing", null says
99
+ * "nobody said"), and only null makes the wave unverifiable. That refusal is
100
+ * safe to make because it is also cheap to fix: `task-spec.schema.json`
101
+ * REQUIRES `budget.tokens`, so a plan reaching here without one never passed
102
+ * `smith plan validate`, and `wave check` reads its plan with a bare cast
103
+ * (see claims.ts's `ProposedWaveTask`) rather than re-validating it. The
104
+ * unpriced spec is the finding.
105
+ *
106
+ * A task NO plan declares is the other silence. A follow-up minted by
107
+ * `findings raise` exists only in the log, which records its claims but never
108
+ * its budget (D-48/P9-31) — there was never a field for anyone to fill in.
109
+ * Refusing it would close the factory's own repair path: the follow-up would
110
+ * be admissible only by hand-editing a plan it was invented precisely to
111
+ * avoid, which is the same shape of failure as guard.sh's `deny`-on-
112
+ * unavailable — not safe, only stuck. So the policy prices it instead, at the
113
+ * coder task cap, which is the most such a task is permitted to spend before
114
+ * D-29's overrun record fires. That is a real number checked against a real
115
+ * cap, not a waiver: a wave of ten follow-ups is ten coder caps, and it is
116
+ * refused when ten of them will not fit.
117
+ */
118
+ function declaredWaveBudgets(plan, taskIds, policy) {
119
+ const priced = new Map(livePlanTasks(plan).map((spec) => [spec.task_id, declaredTokens(spec.budget)]));
120
+ // `has` rather than `??`: an id the plan declares at null is unpriced, an id
121
+ // the plan never mentions at all is log-only. Collapsing them would make the
122
+ // plan defect above unreportable.
123
+ return taskIds.map((taskId) => ({
124
+ taskId,
125
+ tokens: priced.has(taskId) ? (priced.get(taskId) ?? null) : policy.task.coder.capTokens,
126
+ }));
127
+ }
128
+ /**
129
+ * The gate's verdict in the log's snake_case shape, written on every admission.
130
+ *
131
+ * `override_rationale` is passed only when a human actually overrode a
132
+ * blocking status, so an admission that simply fit is never recorded as a
133
+ * decision somebody had to make.
134
+ */
135
+ function admissionBudget(check, rationale) {
136
+ return {
137
+ status: check.status,
138
+ cap_tokens: check.capTokens,
139
+ projected_tokens: check.projectedTokens,
140
+ wave_tokens: check.waveTokens,
141
+ headroom_tokens: check.headroomTokens,
142
+ ...(rationale !== undefined ? { override_rationale: rationale } : {}),
143
+ };
144
+ }
145
+ function requireFlag(flags, name) {
146
+ const value = flags[name];
147
+ if (value === undefined) {
148
+ throw new SmithError('cli.missing-flag', `Missing required flag --${name}.`, { flag: name });
149
+ }
150
+ return value;
151
+ }
152
+ /**
153
+ * requireFlag for a flag that must parse as a number, so `--input-tokens abc`
154
+ * is named here rather than travelling as NaN into a token_usage the schema
155
+ * then rejects for the wrong reason.
156
+ */
157
+ function requireIntFlag(flags, name) {
158
+ const raw = requireFlag(flags, name);
159
+ const value = Number(raw);
160
+ if (!Number.isInteger(value)) {
161
+ throw new SmithError('cli.non-numeric-flag', `--${name} must be an integer, got "${raw}".`, {
162
+ flag: name,
163
+ value: raw,
164
+ });
165
+ }
166
+ return value;
167
+ }
168
+ /**
169
+ * requireIntFlag's optional twin, for a count that also has to be in range.
170
+ *
171
+ * D-210. `event tail --n` read its count with a bare Number.parseInt and
172
+ * checked nothing, which fails in both directions at once: `--n abc` is NaN,
173
+ * and `all.slice(Math.max(0, len - NaN))` is `slice(0)` -- the WHOLE log, from
174
+ * the verb whose usage line promises "the last n records" -- while `--n 0` and
175
+ * `--n -5` make the offset larger and print nothing, which is exactly the
176
+ * "your session is empty" answer P9-28 built requireSession to prevent.
177
+ *
178
+ * Number(), not parseInt(), for the reason `plan quorum --confidence` already
179
+ * documents for parseFloat: parseInt stops at the first character it cannot
180
+ * use and returns the prefix, so `--n 1e2` is 1 rather than 100, `--n 0x10` is
181
+ * 0, and `--port 8080abc` is 8080. Every one of those is a typo answered
182
+ * confidently with the wrong number instead of being named.
183
+ */
184
+ function boundedIntFlag(flags, name, range) {
185
+ const raw = flags[name];
186
+ if (raw === undefined)
187
+ return undefined;
188
+ const value = Number(raw.trim() === '' ? Number.NaN : raw);
189
+ const max = range.max;
190
+ if (!Number.isInteger(value) || value < range.min || (max !== undefined && value > max)) {
191
+ const bound = max === undefined ? `at least ${range.min}` : `${range.min}-${max}`;
192
+ throw new SmithError('cli.invalid-flag', `--${name} must be a whole number ${bound}, got "${raw}".`, { flag: name, value: raw });
193
+ }
194
+ return value;
195
+ }
196
+ /**
197
+ * The same two numbers gate.ts, epic.ts and planQuorum.ts each declare for
198
+ * themselves, and for their reason: a judge call is a network round-trip, not
199
+ * a test. Declared here rather than imported because none of those three
200
+ * exports it, and cli.ts depending on a gate module for a default would make
201
+ * the CLI a fourth caller of the gate rather than a peer of it.
202
+ */
203
+ const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
204
+ /**
205
+ * A judge budget the operator may narrow from the command line.
206
+ *
207
+ * Both bounds are floors, not ceilings, on purpose: a `--timeout-ms 0` would
208
+ * make every call fail as a timeout and read as a provider that refuses to
209
+ * answer, and a `--max-output-bytes 0` would truncate every verdict to nothing
210
+ * and read as a provider that answered with silence. Those are the two
211
+ * failures this repo works hardest to keep distinguishable, so neither is
212
+ * reachable by typo.
213
+ */
214
+ function judgeBudgetFromFlags(flags) {
215
+ return {
216
+ timeout_ms: boundedIntFlag(flags, 'timeout-ms', { min: 1 }) ?? DEFAULT_JUDGE_BUDGET.timeout_ms,
217
+ max_output_bytes: boundedIntFlag(flags, 'max-output-bytes', { min: 1 }) ??
218
+ DEFAULT_JUDGE_BUDGET.max_output_bytes,
219
+ };
220
+ }
221
+ /**
222
+ * requireFlag's twin for positional arguments (P9-28).
223
+ *
224
+ * Every verb below used to take its positionals as `positional[0] as string` —
225
+ * a cast, which checks nothing. The `undefined` then travelled: into
226
+ * readFileSync, which blames a path argument rather than the argument you
227
+ * forgot; into git, run in an undefined directory; and worst, into a log read
228
+ * that returned `[]` and exited 0, so `smith event tail` with no session id
229
+ * reported success and an empty session. Flags have been checked since the
230
+ * first commit; positionals were not checked at all.
231
+ *
232
+ * The names come out of the command's documented positionals, so the error can
233
+ * name the argument that is missing AND print the line that would have worked,
234
+ * with no second list to drift out of sync. Since P9-21 that line comes from
235
+ * usage.ts rather than a literal typed here, which is what lets `--help` print
236
+ * the same text a mistake earns you.
237
+ *
238
+ * `required` is passed only where a verb needs fewer positionals than it
239
+ * documents — after P9-21 that is `wave check <plan.json> <task-id>...` alone,
240
+ * which checks the plan path here and keeps its own, better `cli.empty-wave`
241
+ * message for the variadic tail. The two other overrides died with the split:
242
+ * `worktree verify --before <fingerprint.json>` needed one because its flag's
243
+ * placeholder used to sit in the counted string.
244
+ *
245
+ * Surplus positionals stay accepted on purpose: `wave check` is variadic and
246
+ * `smith new` spends its first token on the project name, so an arity check
247
+ * would reject valid lines. Rejecting *unexpected* arguments is a different
248
+ * question from this one, and this is not it.
249
+ *
250
+ * An empty-string argument counts as missing: `smith gate run ""` is the same
251
+ * mistake as `smith gate run`, usually an unset shell variable, and the empty
252
+ * id would otherwise travel exactly as far as `undefined` did.
253
+ */
254
+ function requirePositionals(positional, doc, required) {
255
+ const usage = usageLine(doc);
256
+ const names = positionalNames(doc);
257
+ const missing = names.slice(0, required ?? names.length).filter((_, i) => !positional[i]);
258
+ if (missing.length > 0) {
259
+ const which = missing.map((name) => `<${name}>`).join(' ');
260
+ throw new SmithError('cli.missing-positional', `Missing required argument${missing.length > 1 ? 's' : ''} ${which}. Usage: ${usage}`, { missing, usage });
261
+ }
262
+ return positional;
263
+ }
264
+ /**
265
+ * Every gate/findings/waivers command writes to the same session log, so they
266
+ * share this shape.
267
+ *
268
+ * `--plan-version` goes through boundedIntFlag (D-210's class): the bare
269
+ * `Number.parseInt` this replaced stamped `--plan-version 2.9` into the
270
+ * persisted envelope as plan 2, `9e9` as 9 and `3x` as 3, and the truthiness
271
+ * test in front of it turned an explicitly empty `--plan-version ""` into the
272
+ * default 1. Only `abc` ever reached the event schema, which then blamed the
273
+ * record for a malformed field rather than the flag that malformed it. This is
274
+ * the shared envelope for twenty-odd verbs and the number outlives the typo, so
275
+ * a fence read back tomorrow is the first place anyone would notice.
276
+ */
277
+ function eventContextFromFlags(flags) {
278
+ return {
279
+ sessionId: requireFlag(flags, 'session'),
280
+ planVersion: boundedIntFlag(flags, 'plan-version', { min: 1 }) ?? 1,
281
+ causalParent: requireFlag(flags, 'causal-parent'),
282
+ actor: flags.actor,
283
+ };
284
+ }
285
+ /**
286
+ * `--state-dir` overrides the events log directory (defaults to the real
287
+ * state/events/ dir, unchanged, when the flag is absent) — lets tests
288
+ * exercise `event append`/`event tail` without littering the real state/
289
+ * dir across runs.
290
+ */
291
+ function eventOptsFromFlags(flags) {
292
+ return flags['state-dir'] ? { stateDir: flags['state-dir'] } : {};
293
+ }
294
+ /** Where plan version files are read from and written to; defaults to factory/specs/active. */
295
+ function planOptsFromFlags(flags) {
296
+ return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
297
+ }
298
+ /**
299
+ * The one reader for every flag usage.ts documents as `<iso>` (D-209).
300
+ *
301
+ * usage.ts states of its flag column: "Documentation only -- never parsed."
302
+ * That was true of the value as well. `scheduler run --now`, `dream --since`
303
+ * and `stats providers --since` each took the operator's string and handed it
304
+ * straight to a Date or to a SQL comparison, so a typo did not fail -- it
305
+ * changed the answer, in a different direction at each of the three:
306
+ *
307
+ * --now: `new Date('now')` is Invalid Date, and every comparison against
308
+ * the NaN behind it is false. proposeRechecks never pushes `time-elapsed`
309
+ * (fails CLOSED -- the operator asks what is due and a typo answers
310
+ * "nothing"), while proposeGrowthReview never returns null (fails OPEN --
311
+ * the review fires on every run regardless of cadence). Worse, the recheck
312
+ * proposal object IS the payload runScheduler persists, and Math.floor(NaN)
313
+ * serialises to null, so `daysElapsed: null` outlives the typo in an
314
+ * append-only log that event.schema.json accepts as-is (`payload` is an
315
+ * unconstrained object).
316
+ *
317
+ * dream --since: `Date.parse(ts) < NaN` is false, so no event is ever
318
+ * skipped and the whole log is distilled instead of the window asked for.
319
+ *
320
+ * stats providers --since: the value goes into `gte(eventsRaw.ts, since)`,
321
+ * a LEXICAL comparison. Every stored ts starts with a digit, so any word
322
+ * sorts above all of them and the report is silently empty.
323
+ *
324
+ * Strict ISO 8601, not "whatever Date accepts", because the parseable inputs
325
+ * are the dangerous ones: V8 reads '01/10/2026' as a US M/D/Y date, so the
326
+ * dd/mm/yyyy operator gets January 10 with no error and no way to see it. A
327
+ * shape check is the only thing that separates that from the date they meant.
328
+ *
329
+ * Returns a Date so the one caller needing an instant gets one; the two
330
+ * comparing against stored timestamps take .toISOString(), which also
331
+ * normalises an offset like +07:00 to the Z form those timestamps are in --
332
+ * the lexical comparison above is only meaningful between like forms.
333
+ */
334
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?$/;
335
+ function isoDateFlag(flags, name) {
336
+ const raw = flags[name];
337
+ if (raw === undefined)
338
+ return undefined;
339
+ const trimmed = raw.trim();
340
+ const parsed = ISO_INSTANT.test(trimmed) ? new Date(trimmed) : new Date(Number.NaN);
341
+ if (Number.isNaN(parsed.getTime())) {
342
+ throw new SmithError('cli.invalid-flag', `--${name} must be an ISO 8601 instant, got "${raw}". The form is ` +
343
+ '2026-08-20 or 2026-08-20T14:30:00Z. Words like "now" are not dates, and ' +
344
+ 'a slashed date is read as US month/day/year, so 01/10/2026 is January ' +
345
+ '10 -- both change the result silently instead of failing.', { flag: name, value: raw });
346
+ }
347
+ return parsed;
348
+ }
349
+ /**
350
+ * Same override, for `db rebuild`/`db apply` — they read the same events log
351
+ * `event append` writes.
352
+ *
353
+ * `--roadmap-path` belongs here too: both verbs rebuild the whole milestones
354
+ * table from a roadmap file, and unset it falls back to black-smith's own
355
+ * factory/specs/roadmap.md. Dropping the flag therefore did not mean "keep the
356
+ * existing milestones" — it meant "replace this db's milestones with
357
+ * black-smith's", which is a silent data swap for any db but this repo's.
358
+ *
359
+ * `--specs-dir` travels for the same reason and is the same flag `plan`
360
+ * already takes: the projector reads an epic's plan file to answer what
361
+ * project its pre-D-232 events never stamped (D-246), and unset it reads this
362
+ * repo's factory/specs/active — another tree's epics, and no answer.
363
+ */
364
+ function dbOptsFromFlags(flags) {
365
+ return {
366
+ ...(flags['state-dir'] ? { stateDir: flags['state-dir'] } : {}),
367
+ ...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
368
+ ...(flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {}),
369
+ };
370
+ }
371
+ /**
372
+ * The novelty gate's two numbers (D-159). architecture §9.3 documents them as
373
+ * living in factory/policies/scheduler.yml, and `LessonsSchedulerPolicy` calls
374
+ * that file the single source of truth — but every path into the gate used to
375
+ * fall back to lessons.ts's own constants, so the file was a knob wired to
376
+ * nothing. `--policy` overrides the path the same way it does on `dispatch
377
+ * check` and friends; unguarded like `computeProposals`, because a missing or
378
+ * malformed policy is a loud `scheduler.invalid-policy`, not a default.
379
+ *
380
+ * `--novelty-threshold` still wins where it is accepted: a one-run override of
381
+ * the standing policy is exactly what an operator reaches for mid-review. It is
382
+ * folded in here rather than spread over the result at each call site (D-208),
383
+ * so one place answers "what threshold is in effect" and no later caller can
384
+ * take the override without its check. `dream` accepts no such flag, and D-132's
385
+ * unknown-flag guard throws in main() before any handler runs, so reading it
386
+ * here cannot smuggle the flag into a command that does not advertise it.
387
+ */
388
+ async function noveltyOptsFromFlags(flags) {
389
+ const override = noveltyThresholdOverride(flags);
390
+ // Async only to keep scheduler.ts out of the boot graph — see the header.
391
+ // Every caller is a lessons/dream branch that is already awaiting something.
392
+ const { loadSchedulerPolicy } = await import('./scheduler.js');
393
+ const { noveltyJaccardThreshold, shingleSize, noveltyLengthAware } = loadSchedulerPolicy(flags.policy).lessons;
394
+ // No flag for the length correction, deliberately: it decides HOW the
395
+ // threshold is read, and an operator who wants a different bar for one run
396
+ // already has --novelty-threshold to say so in the units they are thinking
397
+ // in. A second flag that silently re-scales the first is a worse override.
398
+ return { noveltyThreshold: override ?? noveltyJaccardThreshold, shingleSize, noveltyLengthAware };
399
+ }
400
+ /**
401
+ * The override held to the range its own source of truth is held to (D-208).
402
+ * parseSchedulerPolicy refuses a novelty_jaccard_threshold outside (0, 1] and
403
+ * says why -- "the novelty gate reads this directly and a degenerate value
404
+ * voids it silently" -- but `--novelty-threshold`, which replaces that exact
405
+ * number, went through a bare Number.parseFloat and was checked by nothing, so
406
+ * the flag could express precisely the values the file may not hold.
407
+ *
408
+ * Both ends are silent. No Jaccard score can reach 80, so the percent-for-
409
+ * fraction typo fails OPEN: the duplicate gate never fires, a verbatim
410
+ * re-statement is logged as a `candidate` with exit 0 and no novelty-rejected
411
+ * event, and once approved it joins the corpus every later check scores
412
+ * against. A 0 -- what parseFloat('0,7') silently returns -- fails closed and
413
+ * rejects unrelated lessons as duplicates.
414
+ *
415
+ * Number(), not parseFloat(), for the reason `plan quorum --confidence`
416
+ * already documents: parseFloat stops at the first bad character, so '80%'
417
+ * and '0,7' are accepted as numbers instead of being named as typos.
418
+ */
419
+ function noveltyThresholdOverride(flags) {
420
+ const raw = flags['novelty-threshold'];
421
+ if (raw === undefined)
422
+ return undefined;
423
+ const value = Number(raw.trim());
424
+ if (!Number.isFinite(value) || value <= 0 || value > 1) {
425
+ throw new SmithError('cli.invalid-flag', `--novelty-threshold must be a number in (0, 1], got "${raw}". It overrides ` +
426
+ 'scheduler.yml lessons.novelty_jaccard_threshold, which is held to the same ' +
427
+ 'range: a Jaccard score never exceeds 1, so a larger value passes every ' +
428
+ 'duplicate and a zero rejects every candidate.', { flag: 'novelty-threshold', value: raw });
429
+ }
430
+ return value;
431
+ }
432
+ /**
433
+ * The plan's claims map, as the ownership input routeFindings takes (D-41/P9-24).
434
+ * `TaskSpecRecord.claims` is `unknown` — the plan's own schema is what
435
+ * guarantees it is a string array, so validating it a second time here would
436
+ * only add a second opinion. A task without claims owns no file, and is
437
+ * dropped rather than defaulted to owning everything.
438
+ */
439
+ function ownershipFromPlan(plan) {
440
+ return plan.tasks
441
+ .filter((task) => Array.isArray(task.claims))
442
+ .map((task) => ({ task_id: task.task_id, claims: task.claims }));
443
+ }
444
+ /** `--plan <file>` is optional everywhere findings are raised; without it, ownership is unknown. */
445
+ function ownershipFromFlags(flags) {
446
+ return flags.plan ? ownershipFromPlan(readJsonFile(flags.plan)) : undefined;
447
+ }
448
+ /**
449
+ * Pair every `--evidence <file>` with the `--found-by`/`--found-by-provider`
450
+ * written after it (D-32/P9-13).
451
+ *
452
+ * A task normally has several judges. `task-3-validate` came back with a test
453
+ * gap from the reviewer and a totality violation from the security-reviewer,
454
+ * and the old single-valued read (`flags.evidence` + `requireFlag(flags,
455
+ * 'found-by')`) could only file both under one name — so one of the two
456
+ * attributions was false. That is not a label: `found_by` feeds the
457
+ * same-mistake quorum trigger and every "which role catches what" question the
458
+ * factory asks of its own log.
459
+ *
460
+ * Positional pairing, not a parallel-arrays convention: `--evidence a --evidence
461
+ * b --found-by x --found-by y` reads as two lists that happen to be the same
462
+ * length until the day one of them is not, and then it silently swaps two
463
+ * judges' names.
464
+ *
465
+ * A `--found-by` seen BEFORE any `--evidence` is the default for the sources
466
+ * that name no judge of their own. That is exactly the pre-P9-13 line
467
+ * (`--found-by reviewer --evidence file.json`), which the skill, the operator
468
+ * guide and every existing caller write, and flags were order-independent when
469
+ * they wrote it — a strict "role must follow evidence" rule would turn all of
470
+ * them into errors overnight.
471
+ *
472
+ * An evidence file with neither is refused, naming the file. Falling back to
473
+ * the nearest judge is the misattribution this whole item exists to remove, and
474
+ * it would be silent.
475
+ */
476
+ function evidenceSources(args) {
477
+ const drafts = [];
478
+ let defaultFoundBy;
479
+ let defaultProvider;
480
+ for (const { key, value } of args.ordered) {
481
+ if (key === 'evidence') {
482
+ drafts.push({ file: value });
483
+ continue;
484
+ }
485
+ if (key !== 'found-by' && key !== 'found-by-provider')
486
+ continue;
487
+ // Attaches to the evidence file it follows; before the first one, it is the
488
+ // default. Last occurrence wins within a source, as it does everywhere else
489
+ // in this parser.
490
+ const open = drafts.at(-1);
491
+ if (key === 'found-by') {
492
+ if (open)
493
+ open.foundBy = value;
494
+ else
495
+ defaultFoundBy = value;
496
+ }
497
+ else if (open)
498
+ open.foundByProvider = value;
499
+ else
500
+ defaultProvider = value;
501
+ }
502
+ return drafts.map((draft) => {
503
+ const foundBy = draft.foundBy ?? defaultFoundBy;
504
+ if (foundBy === undefined) {
505
+ throw new SmithError('cli.missing-flag', `--evidence ${draft.file} has no --found-by. Each evidence file needs the judge role that produced it, written after it: --evidence <file> --found-by <role>.`, { flag: 'found-by', evidence: draft.file });
506
+ }
507
+ const foundByProvider = draft.foundByProvider ?? defaultProvider;
508
+ return { file: draft.file, foundBy, ...(foundByProvider ? { foundByProvider } : {}) };
509
+ });
510
+ }
511
+ /**
512
+ * Mint every `--evidence` file's findings under its own judge. Shared by `gate
513
+ * run` and `findings raise` because they have the same judges — fixing one and
514
+ * not the other would leave the misattribution wherever the operator happened
515
+ * to be standing when they recorded the finding.
516
+ */
517
+ function mintFromEvidence(args, taskId, scope = {}) {
518
+ return evidenceSources(args).flatMap((source) => mintFindings(readJsonFile(source.file), {
519
+ taskId,
520
+ foundBy: source.foundBy,
521
+ ...(source.foundByProvider ? { foundByProvider: source.foundByProvider } : {}),
522
+ ...scope,
523
+ }));
524
+ }
525
+ /**
526
+ * The gated task's declared caps, read off the same `--plan` the gate already
527
+ * takes for ownership (D-41/P9-24) — no new flag, because a budget the gate can
528
+ * check is by definition one the plan already stated.
529
+ *
530
+ * A task the plan does not name gets `undefined` rather than an error: a
531
+ * follow-up minted by `findings raise` (D-48/P9-31) is real, gateable work that
532
+ * no plan version has been cut for yet, and refusing to gate it would be a
533
+ * worse answer than reporting its budget as not-declared. Ambiguity still
534
+ * throws — two tasks the id could equally mean is a question, not an absence.
535
+ */
536
+ function budgetFromFlags(flags, taskId) {
537
+ if (!flags.plan)
538
+ return undefined;
539
+ const plan = readJsonFile(flags.plan);
540
+ let resolved;
541
+ try {
542
+ resolved = resolveTaskId(plan, taskId);
543
+ }
544
+ catch (err) {
545
+ if (err instanceof SmithError && err.code === 'plan.unknown-task')
546
+ return undefined;
547
+ throw err;
548
+ }
549
+ const budget = plan.tasks.find((t) => t.task_id === resolved)?.budget;
550
+ return typeof budget === 'object' && budget !== null ? budget : undefined;
551
+ }
552
+ /**
553
+ * The claim globs one dispatch is scoped to, read off the plan (P9-2). Only
554
+ * claim-path-scoped lessons use them, so a role that declares no such scope
555
+ * may omit `--plan` entirely; a `--plan` that names a task it does not contain
556
+ * is an error rather than an empty claims list, which would silently drop
557
+ * every claim-path lesson the task was supposed to see.
558
+ */
559
+ function claimsForDispatch(flags) {
560
+ if (!flags.plan)
561
+ return [];
562
+ const taskId = requireFlag(flags, 'task');
563
+ const owner = ownershipFromPlan(readJsonFile(flags.plan)).find((t) => t.task_id === taskId);
564
+ if (!owner) {
565
+ throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
566
+ taskId,
567
+ plan: flags.plan,
568
+ });
569
+ }
570
+ return owner.claims;
571
+ }
572
+ /**
573
+ * The taxonomy `case` one dispatch is scoped to (D-129) — the selector a
574
+ * `case-type` lesson is filtered by, read off the same immutable plan the
575
+ * claims come from so the two cannot disagree. `--case-type` overrides it for
576
+ * a dispatch that has no plan file to point at.
577
+ *
578
+ * Absent both, this returns '' and `lessonsForDispatch` warns rather than
579
+ * injecting every case's lessons — an unknown case is not a wildcard.
580
+ */
581
+ function caseForDispatch(flags) {
582
+ if (flags['case-type'])
583
+ return flags['case-type'];
584
+ if (!flags.plan)
585
+ return '';
586
+ const taskId = requireFlag(flags, 'task');
587
+ const task = readJsonFile(flags.plan).tasks.find((t) => t.task_id === taskId);
588
+ if (!task) {
589
+ throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
590
+ taskId,
591
+ plan: flags.plan,
592
+ });
593
+ }
594
+ return typeof task.case === 'string' ? task.case : '';
595
+ }
596
+ /**
597
+ * The MCP half of the epic gate (docs/standards/mcp.md step 4). Kept here
598
+ * rather than inside epic.ts for the same reason integrationHeadSha is: epic.ts
599
+ * is a fold over events and stays filesystem-free, so the caller reads the
600
+ * roadmap and the manifest and states the result.
601
+ */
602
+ function mcpSurfaceFor(epicId, projectDir, flags) {
603
+ return resolveMcpSurface({
604
+ epicId,
605
+ projectDir,
606
+ ...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
607
+ });
608
+ }
609
+ /**
610
+ * The goal half of the epic gate. Same division of labour as mcpSurfaceFor:
611
+ * the roadmap read happens out here so epic.ts stays a fold over values it was
612
+ * handed, and `--roadmap-path` points both halves at the same file.
613
+ */
614
+ function epicGoalFor(epicId, flags) {
615
+ return resolveEpicGoal({
616
+ epicId,
617
+ ...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
618
+ });
619
+ }
620
+ /**
621
+ * Most commands are `smith <namespace> <action> ...` (`plan validate`, `wave
622
+ * check`, ...). A few Phase-7 commands are `smith <namespace> <positional>
623
+ * [--flags]` with no action word at all (`smith new <project> [--ui]`,
624
+ * `smith dream [--since ...]`) — telling the two apart on a flag-shaped
625
+ * second token (starts with `--`) keeps every existing two-word command's
626
+ * parsing byte-identical while letting the one-word commands' first
627
+ * positional/flag land in `rest` instead of being swallowed as `action`.
628
+ */
629
+ /**
630
+ * The spellings that mean "tell me what this does" rather than "do it". `help`
631
+ * has to be in the namespace slot's vocabulary because `smith --help` parses as
632
+ * a namespace: splitNamespaceAction treats the first token as the namespace
633
+ * whatever it looks like, and asking it to special-case flags would change how
634
+ * every other command parses.
635
+ */
636
+ const HELP_WORDS = new Set(['help', '--help', '-h']);
637
+ function splitNamespaceAction(argv) {
638
+ const [namespace, ...restAll] = argv;
639
+ const second = restAll[0];
640
+ if (second !== undefined && !second.startsWith('--')) {
641
+ return { namespace, action: second, rest: restAll.slice(1) };
642
+ }
643
+ return { namespace, action: undefined, rest: restAll };
644
+ }
645
+ /**
646
+ * Everything a wave computation reads about a plan: the plan itself with the
647
+ * log's follow-ups merged in, the worktree policy, the import crossings
648
+ * between its tasks, and the live status register.
649
+ *
650
+ * Assembled in one place because `wave next` and `wave schedule` have to read
651
+ * the same plan. Two copies of this would be two definitions of what "the
652
+ * plan" is, and the day they drifted the two commands would answer truthfully
653
+ * about different plans -- the failure that is hardest to see, because both
654
+ * outputs stay internally consistent.
655
+ */
656
+ async function nextWaveInputFrom(planFile, flags) {
657
+ const planOnDisk = readJsonFile(planFile);
658
+ // D-48/P9-31, the rule `wave check` already follows: a follow-up minted by
659
+ // `findings raise` lives in the log and in no plan file. A scheduler that
660
+ // read only the plan would never propose it — the task would exist, be
661
+ // admissible by id, and be offered to nobody. The plan wins for an id both
662
+ // registers know, since a re-cut plan is the newer statement of its claims.
663
+ const events = flags.session
664
+ ? // Lineage-wide (D-119) for the same reason the budget check is: an
665
+ // epic's tasks are not confined to the session that happens to ask.
666
+ await readLineageEvents(flags.session, eventOptsFromFlags(flags))
667
+ : [];
668
+ // Imported here rather than at module scope: `db/projector.js` reaches
669
+ // `db/schema.js` and drizzle behind it, and cliBoot.test.ts holds that out
670
+ // of the graph every `smith` invocation pays — `smith --help` included.
671
+ // Without a session there are no events to fold, so the cost is not even
672
+ // paid by every wave read.
673
+ const statusById = new Map();
674
+ if (events.length > 0) {
675
+ const { foldTasks } = await import('./db/projector.js');
676
+ for (const row of foldTasks(events))
677
+ statusById.set(row.taskId, row.taskStatus);
678
+ }
679
+ const logged = flags.session
680
+ ? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
681
+ : [];
682
+ const planIds = new Set(planOnDisk.tasks.map((t) => t.task_id));
683
+ const followUps = logged
684
+ .filter((t) => !planIds.has(t.taskId))
685
+ .map((t) => ({
686
+ task_id: t.taskId,
687
+ // The log is the only register that holds this task's status, so a
688
+ // missing row means nobody has moved it — which is `todo`, not done.
689
+ task_status: statusById.get(t.taskId) ?? 'todo',
690
+ plan_version: planOnDisk.version,
691
+ // As written, never `?? []` (D-48): substituting an empty list for a
692
+ // claims value nothing can read hands the comparison the one claim set
693
+ // that overlaps nobody, and admits the unreadable task as a disjoint one.
694
+ claims: t.claims,
695
+ }));
696
+ const plan = followUps.length > 0
697
+ ? { ...planOnDisk, tasks: [...planOnDisk.tasks, ...followUps] }
698
+ : planOnDisk;
699
+ // P9-3, the same second question `wave check` asks: two tasks whose claims
700
+ // are disjoint can still be coupled by an import edge between the files
701
+ // those claims match. `wave check` uses the answer to REFUSE a wave someone
702
+ // proposed; the callers here order one instead — the producer is admitted
703
+ // and the consumer deferred with the crossing named, which is the remedy an
704
+ // operator would have applied by hand after the refusal.
705
+ //
706
+ // Through `liveWaveTasks` so the graph is handed claim sets read by the
707
+ // same function that refuses an unreadable one.
708
+ const graph = buildSymbolGraph(collectSources(flags.repo ?? REPO_ROOT));
709
+ const crossings = waveImpact(graph, liveWaveTasks(plan)).crossings;
710
+ return {
711
+ plan,
712
+ policy: loadWorktreePolicy(),
713
+ crossings,
714
+ // The log outranks the plan file on status (D-18b): `task_status` in a
715
+ // plan is the task's INITIAL status, and a plan read from disk hours
716
+ // into a run still says `todo` about work that finished.
717
+ statusById,
718
+ };
719
+ }
720
+ async function main() {
721
+ // Refuse an unsupported runtime before anything opens the database. The
722
+ // native binding crashes lazily — `new Database()`, not import — so a check
723
+ // here still runs, and a subcommand that happens to avoid SQLite must not
724
+ // be allowed to "work" on a runtime where the rest of the CLI segfaults:
725
+ // partial support is exactly how D-47 stayed hidden.
726
+ const runtime = checkRuntime();
727
+ if (!runtime.supported) {
728
+ // Prose on stderr so a human sees it even when stdout is piped into a
729
+ // JSON parser; the structured form still goes to stdout for callers.
730
+ process.stderr.write(`smith: unsupported runtime\n${runtime.reason ?? ''}\n`);
731
+ printJson({ error: { code: 'unsupported-runtime', message: runtime.reason } });
732
+ return 1;
733
+ }
734
+ // Before any command resolves a provider. `enabled: auto` is answered by
735
+ // `apiKeyPresent`, which reads `process.env` -- so a key the operator put
736
+ // where `docs/runbooks/providers.md` told them to has to arrive here first,
737
+ // or the policy resolves off and preflight reports a key the operator can
738
+ // see in the file (D-270). Sets only names the environment does not already
739
+ // hold, and returns names, never values.
740
+ loadDotEnv(DOTENV_PATH);
741
+ const { namespace, action, rest } = splitNamespaceAction(process.argv.slice(2));
742
+ // Parse against what this command declares, not against "anything starting
743
+ // with --" (D-131/D-132). A documented command gets a spec; an undocumented
744
+ // one gets `undefined` and the old permissive parse, because its argv has to
745
+ // survive long enough to print `Unknown command` rather than a flag error
746
+ // about a command that does not exist.
747
+ const args = parseArgs(rest, flagSpecFor(namespace, action));
748
+ const { positional, flags, repeated } = args;
749
+ // `smith help gate run` and `smith gate run --help` ask the same question, so
750
+ // they get the same answer: `help` in the namespace slot shifts the topic one
751
+ // token right, and every other form is the `--help` flag parseArgs already saw.
752
+ const helpWord = namespace !== undefined && HELP_WORDS.has(namespace);
753
+ const topic = helpText(helpWord ? action : namespace, helpWord ? (action === undefined ? undefined : rest[0]) : action);
754
+ if ((helpWord || flags.help === 'true') && topic !== undefined) {
755
+ process.stdout.write(topic);
756
+ return 0;
757
+ }
758
+ // Refuse before dispatch what usage.ts does not describe. This is the half of
759
+ // "the table and the dispatcher agree" that a test cannot enforce — a command
760
+ // added below without a line above cannot run at all, so the help can never
761
+ // be silently incomplete. (test/usage.test.ts enforces the other half.)
762
+ if (!isDocumented(namespace, action)) {
763
+ // Prose on stderr, structured error on stdout — same split as the runtime
764
+ // check above, so a `| jq` pipeline still parses while a human still reads.
765
+ process.stderr.write(topic ?? usageText());
766
+ const given = [namespace, action].filter(Boolean).join(' ');
767
+ printJson({
768
+ error: { message: given === '' ? 'No command given.' : `Unknown command: ${given}` },
769
+ });
770
+ return 1;
771
+ }
772
+ // Same two-step resolution as flagSpecFor, so the usage line printed is the
773
+ // one whose flags were actually enforced. Shared by both refusals below.
774
+ const doc = COMMANDS.find((d) => d.command === `${namespace} ${action}`) ??
775
+ COMMANDS.find((d) => d.command === namespace);
776
+ // D-132. Before this, every `--`-prefixed token went into a bag and each call
777
+ // site reached in for the keys it knew; nothing asked whether a key had been
778
+ // understood by anyone. A typo, a flag borrowed from another subcommand and a
779
+ // correct flag were the same observable event — exit 0, and the default
780
+ // quietly used. It is also what made D-131 dangerous rather than annoying:
781
+ // `--target-dir=<path>` degraded into an unknown flag named
782
+ // `target-dir=<path>`, and this is the check that was not there to catch it.
783
+ if (args.unknown.length > 0) {
784
+ const named = args.unknown.map((f) => `--${f}`).join(', ');
785
+ throw new SmithError('cli.unknown-flag', `Unknown flag${args.unknown.length > 1 ? 's' : ''} for "${[namespace, action].filter(Boolean).join(' ')}": ${named}.`, {
786
+ flag: args.unknown[0],
787
+ flags: args.unknown,
788
+ ...(doc ? { usage: usageLine(doc) } : {}),
789
+ });
790
+ }
791
+ // The other half of D-132. That check asks whether a flag was declared; this
792
+ // one asks whether the declaration was honoured. A flag documented `--task
793
+ // <task-id>` and written bare parsed as the string 'true', and 'true' is a
794
+ // legal value for every flag whose value is a bare string — so the failure
795
+ // was not an error but an answer: an actor named "true" in the append-only
796
+ // log, a session filed under `true.jsonl`, an empty timeline that read as
797
+ // "this task did nothing". cli.ts already carried one hand-written guard
798
+ // against this shape, for `--no-findings`; eighty value-taking flags across
799
+ // sixty-seven commands had none.
800
+ if (args.missingValue.length > 0) {
801
+ const named = args.missingValue.map((f) => `--${f}`).join(', ');
802
+ throw new SmithError('cli.missing-flag-value', `${named} ${args.missingValue.length > 1 ? 'each take a value' : 'takes a value'} and ` +
803
+ `${args.missingValue.length > 1 ? 'were' : 'was'} given none.`, {
804
+ flag: args.missingValue[0],
805
+ flags: args.missingValue,
806
+ ...(doc ? { usage: usageLine(doc) } : {}),
807
+ });
808
+ }
809
+ // -- audit --------------------------------------------------------------
810
+ // Seven verbs, one positional: the project directory (docs/specs/
811
+ // audit-command-scope.md §6). Nothing here reads a cwd, and the only state
812
+ // between verbs is the manifest `audit open` writes under `.blacksmith/`.
813
+ // Axis and decision are validated in audit.ts, so the casts below are the
814
+ // same non-load-bearing shape as `plan validate`'s.
815
+ if (namespace === 'audit' && action === 'open') {
816
+ const [projectDir] = requirePositionals(positional, usageFor('audit open'));
817
+ printJson(await openAudit(projectDir, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
818
+ return 0;
819
+ }
820
+ if (namespace === 'audit' && action === 'record') {
821
+ const [projectDir] = requirePositionals(positional, usageFor('audit record'));
822
+ const axis = requireFlag(flags, 'axis');
823
+ const evidence = readJsonFile(requireFlag(flags, 'evidence'));
824
+ printJson(await recordAudit(projectDir, axis, evidence, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
825
+ return 0;
826
+ }
827
+ if (namespace === 'audit' && action === 'consolidate') {
828
+ const [projectDir] = requirePositionals(positional, usageFor('audit consolidate'));
829
+ printJson(consolidateAudit(projectDir));
830
+ return 0;
831
+ }
832
+ if (namespace === 'audit' && action === 'decide') {
833
+ const [projectDir] = requirePositionals(positional, usageFor('audit decide'));
834
+ printJson(await decideAudit(projectDir, {
835
+ fingerprint: requireFlag(flags, 'fingerprint'),
836
+ decision: requireFlag(flags, 'decision'),
837
+ ...(flags['same-as'] === undefined ? {} : { sameAs: flags['same-as'] }),
838
+ ...(flags.note === undefined ? {} : { note: flags.note }),
839
+ }, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
840
+ return 0;
841
+ }
842
+ if (namespace === 'audit' && action === 'cut') {
843
+ const [projectDir] = requirePositionals(positional, usageFor('audit cut'));
844
+ printJson(await cutAudit(projectDir, { epicId: requireFlag(flags, 'epic'), title: requireFlag(flags, 'title') }, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
845
+ return 0;
846
+ }
847
+ if (namespace === 'audit' && action === 'resolve') {
848
+ const [projectDir] = requirePositionals(positional, usageFor('audit resolve'));
849
+ printJson(await resolveAudit(projectDir, requireFlag(flags, 'epic'), eventContextFromFlags(flags), eventOptsFromFlags(flags)));
850
+ return 0;
851
+ }
852
+ if (namespace === 'audit' && action === 'close') {
853
+ const [projectDir] = requirePositionals(positional, usageFor('audit close'));
854
+ printJson(await closeAudit(projectDir, { force: flags.force === 'true' }, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
855
+ return 0;
856
+ }
857
+ if (namespace === 'plan' && action === 'validate') {
858
+ // The cast is unchanged everywhere below, but it is no longer load-bearing:
859
+ // requirePositionals is what makes it true, and it runs first.
860
+ const [planFile] = requirePositionals(positional, usageFor('plan validate'));
861
+ const plan = readJsonFile(planFile);
862
+ const result = validatePlan(plan);
863
+ printJson(result);
864
+ return result.valid ? 0 : 1;
865
+ }
866
+ if (namespace === 'plan' && action === 'diff') {
867
+ const [fileA, fileB] = requirePositionals(positional, usageFor('plan diff'));
868
+ const vA = readJsonFile(fileA);
869
+ const vB = readJsonFile(fileB);
870
+ printJson(diffPlans(vA, vB));
871
+ return 0;
872
+ }
873
+ if (namespace === 'plan' && action === 'quorum') {
874
+ // Critique-only (planQuorum.ts module header): exit 0 means nothing
875
+ // needs the operator (no trigger fired, or endorsed); exit 1 means the
876
+ // operator must look (critiqued or escalated) before approving the plan.
877
+ const epicId = requireFlag(flags, 'epic');
878
+ // Required for this verb, unlike the shared envelope where it defaults to
879
+ // 1: a quorum is a critique of one specific plan version.
880
+ requireFlag(flags, 'plan-version');
881
+ // Number(), not parseFloat(): both are validated, but parseFloat() would
882
+ // silently accept a garbage suffix — parseFloat('0,7') is 0, not NaN, so a
883
+ // European-decimal typo would read as "zero confidence" and fire trigger 3
884
+ // on every plan. Number('0,7') is NaN and is rejected below. A bare NaN
885
+ // must never reach runPlanQuorum() either: `NaN < threshold` is false, so
886
+ // an unparseable value would fail OPEN — trigger 3's planner arm just never
887
+ // fires and the plan sails through uncritiqued. A gate trigger must be
888
+ // neither disabled nor pinned on by a typo.
889
+ const rawConfidence = flags.confidence?.trim();
890
+ const plannerConfidence = rawConfidence ? Number(rawConfidence) : undefined;
891
+ if ((flags.confidence !== undefined && !rawConfidence) ||
892
+ (plannerConfidence !== undefined &&
893
+ (!Number.isFinite(plannerConfidence) || plannerConfidence < 0 || plannerConfidence > 1))) {
894
+ throw new SmithError('cli.invalid-flag', `--confidence must be a number in [0, 1], got "${flags.confidence}".`, { flag: 'confidence', value: flags.confidence });
895
+ }
896
+ const ctx = eventContextFromFlags(flags);
897
+ // D-211: the version comes off `ctx`, not off a second read of the flag.
898
+ // This verb used to parse `--plan-version` twice with two parsers that
899
+ // disagree — a bare `Number.parseInt` chose the plan FILE to critique,
900
+ // while boundedIntFlag inside eventContextFromFlags chose the plan_version
901
+ // stamped on every event the run appends. D-210 settled the notation:
902
+ // `1e2` is an unambiguous numeric literal for 100, and answering with the
903
+ // 1 of its first character is the defect. So `--plan-version 1e2`
904
+ // critiqued v1 and logged v100, with no error anywhere — and
905
+ // planQuorum.ts's `inputRefs.plan_version`, which reads the version out of
906
+ // the loaded FILE, then disagreed with the envelope around it. A record
907
+ // that names two different plans is worse than one that fails: reading it
908
+ // back, neither number can be trusted. One read, one number.
909
+ const version = ctx.planVersion;
910
+ const outcome = await runPlanQuorum({ epicId, version, ...(plannerConfidence !== undefined ? { plannerConfidence } : {}) }, ctx, eventOptsFromFlags(flags));
911
+ printJson(outcome);
912
+ return outcome.outcome === 'endorsed' ? 0 : 1;
913
+ }
914
+ if (namespace === 'plan' && action === 'ingest') {
915
+ // D-46/P9-29: where a task starts existing as far as the log — and so
916
+ // the DB, the kanban and every dashboard number — is concerned. Before
917
+ // this, a task row only sprang into being as a side effect of whatever
918
+ // wave or gate event first happened to name an id, with no epic, no
919
+ // claims and no budget. Idempotent, because ingesting the plan again
920
+ // (a resumed session, a v(n+1) that carries tasks forward) is normal.
921
+ const [planFile] = requirePositionals(positional, usageFor('plan ingest'));
922
+ const plan = readJsonFile(planFile);
923
+ const ctx = eventContextFromFlags(flags);
924
+ const opts = eventOptsFromFlags(flags);
925
+ const written = await emitTasksAdded(plan, ctx, opts);
926
+ // D-254: the arrows, after the nodes. The plan declares a DAG and the
927
+ // scheduler has always read it off the file, but nothing wrote it to the
928
+ // log -- so the db's `edges` table was empty and every operator-facing
929
+ // view of the graph showed a flat list. Counted in the output for the
930
+ // same reason `added` is: an ingest that silently wrote none is exactly
931
+ // what went unnoticed.
932
+ const edges = await emitEdgesRecorded(plan, ctx, opts);
933
+ const added = written.filter((e) => e.record.event_type === 'task-added').length;
934
+ printJson({
935
+ epic: plan.epic_id,
936
+ version: plan.version,
937
+ added,
938
+ superseded: written.length - added,
939
+ skipped: plan.tasks.length - added,
940
+ edges: edges.length,
941
+ });
942
+ return 0;
943
+ }
944
+ // P9-9/D-33: the one legitimate way to change an immutable plan. It refuses
945
+ // to cut a version that cites no spec finding, so "the plan changed" is
946
+ // always answerable with "which finding said it was wrong".
947
+ if (namespace === 'plan' && action === 'amend') {
948
+ const plan = readJsonFile(requireFlag(flags, 'plan'));
949
+ // Comma-split, not repeated: a finding id has no commas, and an amendment
950
+ // routinely cites several at once.
951
+ const findingIds = requireFlag(flags, 'findings')
952
+ .split(',')
953
+ .map((id) => id.trim())
954
+ .filter((id) => id !== '');
955
+ // Same comma split, for the same reason: a path has no commas either, and
956
+ // the whole point of D-123 is that this list is routinely longer than one.
957
+ const sites = requireFlag(flags, 'sites')
958
+ .split(',')
959
+ .map((s) => s.trim())
960
+ .filter((s) => s !== '');
961
+ const changes = flags.changes ? readJsonFile(flags.changes) : undefined;
962
+ const result = await amendPlan({
963
+ plan,
964
+ findingIds,
965
+ rationale: requireFlag(flags, 'rationale'),
966
+ sites,
967
+ ...(changes ? { changes } : {}),
968
+ }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
969
+ // No no-op warning here any more. A pure carry-forward used to be legal
970
+ // and merely suspicious — the shape a forgotten --changes takes — so the
971
+ // CLI printed a warning and cut the version anyway. Since D-127 an
972
+ // amendment that adds and supersedes nothing is refused by `amendPlan`
973
+ // itself, before the version exists: the cited finding would have had
974
+ // nothing to wait on. The check belongs there and not here, because the
975
+ // library is where the finding is transitioned; a guard living only in
976
+ // the CLI would let any other caller close an unwaivable finding with an
977
+ // identical plan version.
978
+ printJson({
979
+ epic: result.plan.epic_id,
980
+ version: result.plan.version,
981
+ previousVersion: plan.version,
982
+ findingIds,
983
+ sites,
984
+ // Printed at authorship, not only recorded for the close: the operator
985
+ // who just named these is the one best placed to say whether a site with
986
+ // no task behind it is a deliberate call or a forgotten one.
987
+ sitesUnclaimed: result.sitesUnclaimed,
988
+ diff: result.diff,
989
+ });
990
+ return 0;
991
+ }
992
+ // The worker's half of the same wall. A coder cannot emit an event and
993
+ // cannot mint a spec-scoped finding, so it returns a spec_change_request in
994
+ // its structured_output and the node that dispatched it runs this. Nothing
995
+ // here writes a plan file: `plan amend` above stays the only path to a
996
+ // version, and this only records that someone asked for one.
997
+ if (namespace === 'plan' && action === 'propose') {
998
+ const plan = readJsonFile(requireFlag(flags, 'plan'));
999
+ // A file, not a flag soup: `changes` alone is a nested object, and the
1000
+ // worker already returned the whole request as JSON. Asking the dispatcher
1001
+ // to re-type it into flags would be asking it to paraphrase the worker.
1002
+ const request = readJsonFile(requireFlag(flags, 'request'));
1003
+ const proposal = await proposeSpecChange({
1004
+ plan,
1005
+ taskId: requireFlag(flags, 'task'),
1006
+ proposedBy: requireFlag(flags, 'proposed-by'),
1007
+ ...(flags['proposed-by-provider']
1008
+ ? { proposedByProvider: flags['proposed-by-provider'] }
1009
+ : {}),
1010
+ request,
1011
+ }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
1012
+ printJson(proposal);
1013
+ return 0;
1014
+ }
1015
+ if (namespace === 'plan' && action === 'proposals') {
1016
+ const sessionId = requireFlag(flags, 'session');
1017
+ const eventOpts = eventOptsFromFlags(flags);
1018
+ // Same P9-28 rule the findings verbs follow: an empty list is an answer
1019
+ // about the proposals, and it must not double as the answer about a
1020
+ // session that was never opened.
1021
+ requireSession(sessionId, eventOpts);
1022
+ const proposals = await listSpecChanges(sessionId, {
1023
+ epicId: flags.epic,
1024
+ taskId: flags.task,
1025
+ status: flags.status,
1026
+ }, { ...eventOpts, ...planOptsFromFlags(flags) });
1027
+ // Printed whole, diff included. The operator's next move is a yes or a no
1028
+ // on a plan diff, and a listing that made them go and read the proposal
1029
+ // event by hand to see it would have answered the wrong question.
1030
+ printJson(proposals);
1031
+ return 0;
1032
+ }
1033
+ // "Approve it fast" is one command, and it is one command without any guard
1034
+ // being relaxed: `amendPlan` still demands a rationale, findings, and
1035
+ // sites. Approval supplies them from what the worker already recorded.
1036
+ if (namespace === 'plan' && action === 'approve') {
1037
+ const [proposalId] = requirePositionals(positional, usageFor('plan approve'));
1038
+ const plan = readJsonFile(requireFlag(flags, 'plan'));
1039
+ const result = await approveSpecChange({
1040
+ proposalId,
1041
+ plan,
1042
+ decidedBy: requireFlag(flags, 'decided-by'),
1043
+ ...(flags.rationale ? { rationale: flags.rationale } : {}),
1044
+ }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
1045
+ printJson({
1046
+ proposalId,
1047
+ epic: result.plan.epic_id,
1048
+ version: result.plan.version,
1049
+ previousVersion: plan.version,
1050
+ findingIds: [result.proposal.findingId],
1051
+ sites: result.proposal.sites,
1052
+ sitesUnclaimed: result.sitesUnclaimed,
1053
+ diff: result.diff,
1054
+ });
1055
+ return 0;
1056
+ }
1057
+ if (namespace === 'plan' && action === 'reject') {
1058
+ const [proposalId] = requirePositionals(positional, usageFor('plan reject'));
1059
+ // --rationale is required here and optional on approve, which is not an
1060
+ // inconsistency: approval can fall back to the worker's own argument
1061
+ // because approval agrees with it. A rejection is the operator saying
1062
+ // something the log does not already contain.
1063
+ const proposal = await rejectSpecChange({
1064
+ proposalId,
1065
+ decidedBy: requireFlag(flags, 'decided-by'),
1066
+ rationale: requireFlag(flags, 'rationale'),
1067
+ }, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
1068
+ printJson(proposal);
1069
+ return 0;
1070
+ }
1071
+ if (namespace === 'wave' && action === 'check') {
1072
+ // `required: 1` — the variadic tail has its own, better message below.
1073
+ const [planFile] = requirePositionals(positional, usageFor('wave check'), 1);
1074
+ const plan = readJsonFile(planFile);
1075
+ // D-46/P9-29: every id is minted from the plan, not taken as typed. The
1076
+ // old code filtered the plan's tasks by `taskIds.includes(t.task_id)`,
1077
+ // so a wave named in the other id convention matched NOTHING and
1078
+ // validateWave([]) pronounced the empty set admissible — a wave could
1079
+ // pass this check without a single one of its tasks having been looked
1080
+ // at. resolveTaskId turns that silence into a named error.
1081
+ const typedIds = positional.slice(1);
1082
+ if (typedIds.length === 0) {
1083
+ 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 });
1084
+ }
1085
+ // D-48/P9-31: a follow-up task exists only in the log, so the log is the
1086
+ // second register an id may be found in — and the place its claims come
1087
+ // from. Read only when there is a session to read; the plan wins for an
1088
+ // id both know, since a re-cut plan is the newer statement of its claims.
1089
+ const logged = flags.session
1090
+ ? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
1091
+ : [];
1092
+ const taskIds = typedIds.map((typed) => resolveTaskId(plan, typed, logged.map((t) => t.taskId)));
1093
+ // Neither register is narrowed on the way in. `?? []` used to stand here
1094
+ // and on the line above, and it fires on a missing claims field but not on
1095
+ // a claims field holding the wrong thing: a plan writing `"claims":
1096
+ // "src/api/**"` reached the comparison as a string and was iterated by
1097
+ // character. Both defaults said "claims nothing", which validateWave read
1098
+ // as "disjoint from everyone" — the answer that admits the wave. The shape
1099
+ // is that function's to judge, once, where the comparison happens.
1100
+ const claimsById = new Map(logged.map((t) => [t.taskId, t.claims]));
1101
+ for (const t of plan.tasks) {
1102
+ claimsById.set(t.task_id, t.claims);
1103
+ }
1104
+ const tasks = taskIds.map((id) => ({
1105
+ task_id: id,
1106
+ claims: claimsById.get(id),
1107
+ }));
1108
+ const policy = loadWorktreePolicy();
1109
+ // D-212: the plan has been in hand since the top of this verb, and it is
1110
+ // the register that says which of these tasks may not run beside which.
1111
+ const result = validateWave(tasks, policy, plan.edges);
1112
+ // P9-3: the claim check compares two lists of globs. This compares the
1113
+ // edges between the files those globs match, which is where the conflict
1114
+ // a disjoint claim list cannot see actually lives — task A changes
1115
+ // `parse()`, task B calls it, neither writes the other's file. A crossing
1116
+ // between two tasks the plan declared no edge between is a dependency the
1117
+ // planner missed, so the wave is refused and the remedy is to run them in
1118
+ // order. (A crossing the plan DID declare never reaches here: validateWave
1119
+ // above already refuses a wave holding both ends of a declared edge.)
1120
+ //
1121
+ // Only asked of a wave that survived the claim check: validateWave is what
1122
+ // proves the claims are readable at all, and a wave already refused is
1123
+ // better named by the failure an operator can act on first.
1124
+ const symbolImpact = result.valid
1125
+ ? waveImpact(buildSymbolGraph(collectSources(flags.repo ?? REPO_ROOT)),
1126
+ // Readable by construction — validateWave threw otherwise.
1127
+ tasks)
1128
+ : null;
1129
+ const coupled = symbolImpact !== null && !symbolImpact.ok;
1130
+ // The epic budget gate, checked after the claim check so a wave that fails
1131
+ // both is still named by the failure an operator can act on first — and so
1132
+ // the claim errors above keep their exact wording. Two separate questions:
1133
+ // `valid` answers "may these run beside each other", `budget` answers "can
1134
+ // this epic afford to start them", and the JSON carries both because a
1135
+ // wave refused for cost is not an invalid wave.
1136
+ //
1137
+ // Upstream of every dispatch on purpose. D-29 rules that the *task* cap
1138
+ // must only record an overrun, because a self-policed cap on work already
1139
+ // running becomes pressure on the work being measured. Wave admission is
1140
+ // the other side of that line: nothing in the wave has been dispatched
1141
+ // yet, so refusing here distorts no work — it decides whether the work
1142
+ // begins.
1143
+ const rationaleFlag = flags['override-rationale'];
1144
+ const rationale = rationaleFlag?.trim() ?? '';
1145
+ if (rationaleFlag !== undefined && rationale === '') {
1146
+ // Mirrors epic.ts's close-refused check (D-43/P9-27): the reason is the
1147
+ // whole point of recording an override, and a blank one would leave the
1148
+ // log saying a human decided this with nothing to say what they decided.
1149
+ throw new SmithError('cli.blank-override-rationale', 'Refusing to admit this wave with a blank --override-rationale: the reason is the whole point of recording the override.', { plan: plan.epic_id });
1150
+ }
1151
+ const budgetEvents = flags.session
1152
+ ? // D-119: lineage-wide, not this session's own log — an epic's spend is
1153
+ // the sum of every session that worked on it.
1154
+ await readLineageEvents(flags.session, eventOptsFromFlags(flags))
1155
+ : [];
1156
+ const budgetPolicy = loadBudgetPolicy(flags['budget-policy']);
1157
+ const budget = checkWaveBudget(budgetEvents, budgetPolicy, declaredWaveBudgets(plan, taskIds, budgetPolicy), { sessionId: flags.session ?? '', epicId: plan.epic_id });
1158
+ const overridden = rationale !== '' && blocksAdmission(budget.status);
1159
+ const blocked = blocksAdmission(budget.status) && !overridden;
1160
+ // Log only an admissible wave, and only after it is known to be one:
1161
+ // `wave-admitted` is what moves a task to `ready`, so writing it for a
1162
+ // wave that just failed its claim-disjointness check would record an
1163
+ // admission that never happened. `--dry` asks the question without
1164
+ // answering it in the log.
1165
+ if (result.valid && !coupled && !blocked && flags.dry !== 'true') {
1166
+ await emitWaveAdmitted(plan, taskIds, eventContextFromFlags(flags), eventOptsFromFlags(flags), admissionBudget(budget, overridden ? rationale : undefined));
1167
+ }
1168
+ printJson({ ...result, symbolImpact, budget });
1169
+ return result.valid && !coupled && !blocked ? 0 : 1;
1170
+ }
1171
+ if (namespace === 'wave' && action === 'next') {
1172
+ const [planFile] = requirePositionals(positional, usageFor('wave next'));
1173
+ const result = computeNextWave(await nextWaveInputFrom(planFile, flags));
1174
+ printJson(result);
1175
+ // Writes nothing on purpose. `wave-admitted` is what moves a task to
1176
+ // `ready`, and this command proposes rather than admits — recording an
1177
+ // admission here would move tasks nobody had yet agreed to start, and
1178
+ // leave `wave check` re-admitting what the log already said was admitted.
1179
+ //
1180
+ // The cost question is deliberately not asked here. `wave check` refuses a
1181
+ // wave the epic cannot afford, and it stays the only place that verdict is
1182
+ // rendered — a proposer that also priced the wave would be a second opinion
1183
+ // about the same fact, and would have to decide which task to drop, which
1184
+ // is an operator's call and not a graph's. So a proposed wave can still be
1185
+ // refused for cost on the next line of the playbook; that is the gate
1186
+ // working, not the proposal being wrong.
1187
+ //
1188
+ // Exit 1 names a stall, not an empty answer: work is left, and none of it
1189
+ // can begin. A loop driving waves needs that distinguished from the epic
1190
+ // simply being finished, which is an empty wave with nothing remaining.
1191
+ return result.wave.length > 0 || result.remaining === 0 ? 0 : 1;
1192
+ }
1193
+ if (namespace === 'wave' && action === 'schedule') {
1194
+ const [planFile] = requirePositionals(positional, usageFor('wave schedule'));
1195
+ // Static would be fine — waveSchedule.js reaches nothing waveNext.js has
1196
+ // not already put on the boot path — but the input assembly it is handed
1197
+ // may dynamically import `db/projector.js`, so the await is here anyway.
1198
+ const { scheduleWaves } = await import('./waveSchedule.js');
1199
+ const schedule = scheduleWaves(await nextWaveInputFrom(planFile, flags));
1200
+ printJson(schedule);
1201
+ // Writes nothing, for the reason `wave next` writes nothing and one more:
1202
+ // every round after the first is a simulation. The tasks it marks complete
1203
+ // were completed by nobody, and a log that recorded them would be claiming
1204
+ // work that has not happened.
1205
+ return schedule.exitCode;
1206
+ }
1207
+ if (namespace === 'wave' && action === 'audit') {
1208
+ // Dynamic for P9-2 (test/cliBoot.test.ts): the module reaches
1209
+ // agents-registry.ts, and nothing that only `wave audit` needs should be
1210
+ // on the path every `smith --help` walks.
1211
+ const { auditWaveConcurrency, summariseWaveConcurrency } = await import('./waveConcurrency.js');
1212
+ const sessionId = requireFlag(flags, 'session');
1213
+ const eventOpts = eventOptsFromFlags(flags);
1214
+ requireSession(sessionId, eventOpts);
1215
+ // Lineage-wide, for the reason `wave next` and the budget check already
1216
+ // are (D-119): an epic's waves are not confined to the session that
1217
+ // happens to ask, and a wave admitted in session 1 whose agents ran in
1218
+ // session 2 would read as `unobserved` -- the factory reported broken on
1219
+ // nothing but where the operator stood.
1220
+ const summary = summariseWaveConcurrency(auditWaveConcurrency(await readLineageEvents(sessionId, eventOpts), {
1221
+ epicId: typeof flags.epic === 'string' ? flags.epic : undefined,
1222
+ }));
1223
+ printJson(summary);
1224
+ return summary.exitCode;
1225
+ }
1226
+ if (namespace === 'new') {
1227
+ // `smith new <project> [--ui]` — action doubles as the positional
1228
+ // project name here (splitNamespaceAction), never a subcommand word.
1229
+ // `--target-dir`/`--roadmap-path` override the real projects root and
1230
+ // factory/specs/roadmap.md paths (same override pattern as
1231
+ // `--state-dir` elsewhere in this file) so tests never touch either.
1232
+ if (!action) {
1233
+ // Checked all along, but filed under the flag code; one kind of mistake,
1234
+ // one code (P9-28). The project name cannot go through
1235
+ // requirePositionals because splitNamespaceAction spends it on `action`.
1236
+ const usage = usageLine(usageFor('new'));
1237
+ throw new SmithError('cli.missing-positional', `Missing required argument <project>. Usage: ${usage}`, { missing: ['project'], usage });
1238
+ }
1239
+ const result = scaffoldProject({
1240
+ projectName: action,
1241
+ ui: flags.ui === 'true',
1242
+ ...(flags['target-dir'] ? { targetDir: flags['target-dir'] } : {}),
1243
+ // P9-19: `smith new` installs the toolchain and runs the project's own
1244
+ // gates before it commits, so the first epic planned here starts against
1245
+ // a repo whose checks are known to pass. --skip-toolchain is for an
1246
+ // offline operator; the report then says `skipped`, never a green.
1247
+ ...(flags['skip-toolchain'] === 'true' ? { skipToolchain: true } : {}),
1248
+ });
1249
+ registerProjectInRoadmap(action, flags['roadmap-path']);
1250
+ printJson(result);
1251
+ // Red gates are reported, not hidden: the tree stays for the operator to
1252
+ // fix, and the exit code says the toolchain was not proven.
1253
+ return result.toolchain.status === 'failed' ? 1 : 0;
1254
+ }
1255
+ if (namespace === 'mcp' && action === 'init') {
1256
+ // Deliberately not folded into `smith new`: the MCP surface is due at the
1257
+ // END of a project, when the tools it should expose are known, and the
1258
+ // milestone this registers is what makes that due date real. Scaffolding it
1259
+ // on day one would ship a manifest declaring nothing, which is exactly the
1260
+ // rubber-stamp the standard exists to prevent.
1261
+ const [projectName] = requirePositionals(positional, usageFor('mcp init'));
1262
+ const result = addMcpSurface({
1263
+ projectName,
1264
+ ...(flags['target-dir'] ? { targetDir: flags['target-dir'] } : {}),
1265
+ ...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
1266
+ });
1267
+ printJson(result);
1268
+ return 0;
1269
+ }
1270
+ // The verdict `smith epic close` gates on, rendered on its own so it can be
1271
+ // read before the gate refuses. Exit 1 on red — same convention as `plan
1272
+ // validate` and `gate run`, so CI needs no output parsing.
1273
+ if (namespace === 'mcp' && action === 'check') {
1274
+ const [projectName] = requirePositionals(positional, usageFor('mcp check'));
1275
+ const report = runMcpCheck({
1276
+ projectName,
1277
+ ...(flags['target-dir'] ? { targetDir: flags['target-dir'] } : {}),
1278
+ ...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
1279
+ });
1280
+ printJson(report);
1281
+ return report.ok ? 0 : 1;
1282
+ }
1283
+ if (namespace === 'scheduler' && action === 'run') {
1284
+ const { computeProposals, runScheduler } = await import('./scheduler.js');
1285
+ const sessionId = requireFlag(flags, 'session');
1286
+ const dry = flags.dry === 'true';
1287
+ requireSession(sessionId, eventOptsFromFlags(flags));
1288
+ // The lineage (D-119). The scheduler's idempotency is positional — a
1289
+ // proposal counts as resolved when the events resolving it appear LATER in
1290
+ // the log — so a session-scoped read from a continuation finds none of the
1291
+ // parent's proposals and re-proposes every recheck the epic already ran.
1292
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
1293
+ const now = isoDateFlag(flags, 'now') ?? new Date();
1294
+ // `repeated`, not `flags`: --project may name several repos, and the
1295
+ // last-occurrence-wins read would silently watch only the final one.
1296
+ // This clone joins the pass by default (contract clause 1); `--no-self`
1297
+ // is the refusal path.
1298
+ const { resolveProjectDirs } = await import('./projects.js');
1299
+ const projectDirs = resolveProjectDirs(repeated.project, {
1300
+ self: flags['no-self'] !== 'true',
1301
+ });
1302
+ const input = { events, now, projectDirs };
1303
+ if (dry) {
1304
+ printJson({ proposals: computeProposals(input) });
1305
+ return 0;
1306
+ }
1307
+ const ctx = eventContextFromFlags(flags);
1308
+ const result = await runScheduler(input, {
1309
+ sessionId: ctx.sessionId,
1310
+ planVersion: ctx.planVersion,
1311
+ causalParent: ctx.causalParent,
1312
+ actor: ctx.actor,
1313
+ }, eventOptsFromFlags(flags), false);
1314
+ printJson(result);
1315
+ return 0;
1316
+ }
1317
+ // The other half of a scheduler tick. `run --dry` answers "what is due";
1318
+ // this answers "which of that may proceed without me", and answers it out
1319
+ // loud, in one place, from scheduler.yml's `autonomy:` block alone.
1320
+ //
1321
+ // It enacts nothing. Splitting the classification out of the dispatch is
1322
+ // the whole safety property: an operator can read what the policy would
1323
+ // have let run, and argue with it, before any of it moves. So this appends
1324
+ // no event, writes no worktree and starts no agent -- exactly the daemon's
1325
+ // invariant, held by the command a person types too.
1326
+ if (namespace === 'scheduler' && action === 'admit') {
1327
+ const { computeProposals, loadSchedulerPolicy } = await import('./scheduler.js');
1328
+ const { admitProposals } = await import('./autonomy.js');
1329
+ // Kept out of the boot graph on purpose: db/projector.js reaches
1330
+ // db/schema.js, and test/cliBoot.test.ts pins that `smith --help` never
1331
+ // pays for it. A top-level import here would be invisible until that test
1332
+ // failed (P9-2).
1333
+ const { foldTasks } = await import('./db/projector.js');
1334
+ const sessionId = requireFlag(flags, 'session');
1335
+ requireSession(sessionId, eventOptsFromFlags(flags));
1336
+ // Lineage, for the same reason `run` reads it: proposal idempotency is
1337
+ // positional, so a session-scoped read from a continuation would classify
1338
+ // proposals the parent already resolved.
1339
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
1340
+ const now = isoDateFlag(flags, 'now') ?? new Date();
1341
+ // Read once and passed down both halves. `--policy` has to govern which
1342
+ // proposals exist as well as who may say yes to them: a file that retuned
1343
+ // `recheck.days_elapsed` but was consulted only for `autonomy:` would
1344
+ // classify proposals the operator's own policy never made.
1345
+ const policy = loadSchedulerPolicy(flags.policy);
1346
+ // This clone joins the pass by default (contract clause 1); `--no-self`
1347
+ // is the refusal path.
1348
+ const { resolveProjectDirs } = await import('./projects.js');
1349
+ const proposals = computeProposals({
1350
+ events,
1351
+ now,
1352
+ policy,
1353
+ projectDirs: resolveProjectDirs(repeated.project, { self: flags['no-self'] !== 'true' }),
1354
+ });
1355
+ // A RecheckProposal names a task and no paths, so without this the
1356
+ // security match would see an opaque id and clear a recheck of
1357
+ // src/auth/session.ts. The claims come from the log, through the same
1358
+ // fold every other reader uses.
1359
+ const claimsByTask = new Map();
1360
+ for (const task of foldTasks(events)) {
1361
+ if (task.claims && task.claims.length > 0)
1362
+ claimsByTask.set(task.taskId, task.claims);
1363
+ }
1364
+ const admissions = admitProposals(proposals, policy.autonomy, {
1365
+ // One list, read from crosscheck.yml rather than copied into
1366
+ // scheduler.yml: promoting a word has to move the cross-check trigger
1367
+ // and this gate together, or a keyword added in one place quietly
1368
+ // stops meaning anything in the other.
1369
+ securityKeywords: loadCrosscheckPolicy(flags.crosscheck).planQuorum.securityKeywords,
1370
+ claimsByTask,
1371
+ });
1372
+ printJson({ admissions });
1373
+ return 0;
1374
+ }
1375
+ // The background watcher (Phase 10). Four verbs and one invariant: none of
1376
+ // them dispatches an agent, merges a branch or writes to a worktree. A
1377
+ // process that outlives the operator's terminal is the last place to relax
1378
+ // the rule that a human admits work.
1379
+ if (namespace === 'daemon' && action === 'run') {
1380
+ const { DEFAULT_DAEMON_DIR, DEFAULT_INTERVAL_SECONDS, runDaemon } = await import('./daemon.js');
1381
+ const dir = flags.dir ?? DEFAULT_DAEMON_DIR;
1382
+ const interval = flags.interval === undefined ? DEFAULT_INTERVAL_SECONDS : Number(flags.interval);
1383
+ if (!Number.isInteger(interval) || interval <= 0) {
1384
+ throw new SmithError('cli.invalid-flag', `--interval must be a whole number of seconds greater than zero, got "${flags.interval}".`, { flag: 'interval', value: flags.interval ?? null });
1385
+ }
1386
+ // The register that judges `--project`, supplied here rather than defaulted
1387
+ // inside the fold: this is where the flags are read, so this is where the
1388
+ // list they should have covered belongs. A tick handed no register raises
1389
+ // no `unwatched-project`, which keeps inspectFactory's answer a function of
1390
+ // its arguments.
1391
+ const { factoryProjects, resolveProjectDirs } = await import('./projects.js');
1392
+ // This clone joins the pass by default (contract clause 1); `--no-self`
1393
+ // is the refusal path. It has to drop this clone from BOTH sides of the
1394
+ // fold: leaving it in the register while pulling it from `projectDirs`
1395
+ // would still fold to an `unwatched-project` finding for it, which is
1396
+ // exactly the alarm `--no-self` exists to silence.
1397
+ const self = flags['no-self'] !== 'true';
1398
+ const tickOpts = {
1399
+ ...eventOptsFromFlags(flags),
1400
+ projectDirs: resolveProjectDirs(repeated.project, { self }),
1401
+ readProjects: () => factoryProjects().filter((ref) => self || !ref.self),
1402
+ ...(flags.db ? { dbPath: flags.db } : {}),
1403
+ ...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
1404
+ };
1405
+ // SIGTERM has to reach the sleep, not just the flag: a daemon woken only
1406
+ // by the next tick would ignore `smith daemon stop` for up to an interval,
1407
+ // and an operator who waits that long reaches for `kill -9` — which is
1408
+ // exactly the exit that strands the lock.
1409
+ let stopping = false;
1410
+ let wake = null;
1411
+ const requestStop = () => {
1412
+ stopping = true;
1413
+ wake?.();
1414
+ };
1415
+ process.once('SIGTERM', requestStop);
1416
+ process.once('SIGINT', requestStop);
1417
+ const reports = await runDaemon({
1418
+ dir,
1419
+ intervalSeconds: interval,
1420
+ ...tickOpts,
1421
+ ...(flags.once === 'true' ? { once: true } : {}),
1422
+ shouldContinue: () => !stopping,
1423
+ sleep: (ms) => new Promise((resolve) => {
1424
+ const timer = setTimeout(() => {
1425
+ wake = null;
1426
+ resolve();
1427
+ }, ms);
1428
+ wake = () => {
1429
+ clearTimeout(timer);
1430
+ wake = null;
1431
+ resolve();
1432
+ };
1433
+ }),
1434
+ });
1435
+ const last = reports[reports.length - 1];
1436
+ printJson({ ticks: reports.length, dir, ...(last === undefined ? {} : { last }) });
1437
+ return 0;
1438
+ }
1439
+ if (namespace === 'daemon' && action === 'start') {
1440
+ const { DaemonError, DEFAULT_DAEMON_DIR, readLock } = await import('./daemon.js');
1441
+ const dir = flags.dir ?? DEFAULT_DAEMON_DIR;
1442
+ const held = readLock(dir);
1443
+ if (held !== null) {
1444
+ // Let acquireLock own the liveness question — one implementation of
1445
+ // "is that pid still there" rather than a second opinion here.
1446
+ throw new DaemonError('daemon.already-running', `A daemon (pid ${held.pid}, started ${held.startedAt}) already holds ${dir}. ` +
1447
+ 'Run `smith daemon stop` first, or `smith daemon status` to see what it last found.', { pid: held.pid, dir });
1448
+ }
1449
+ mkdirSync(dir, { recursive: true });
1450
+ // `ignore` would discard the one account of why a detached daemon died.
1451
+ const logFd = openSync(path.join(dir, 'daemon.log'), 'a');
1452
+ const argv = [
1453
+ 'daemon',
1454
+ 'run',
1455
+ '--dir',
1456
+ dir,
1457
+ ...(flags.interval === undefined ? [] : ['--interval', flags.interval]),
1458
+ // One `--project` per repo, so the detached child watches every repo the
1459
+ // operator named rather than the last one they typed.
1460
+ ...(repeated.project ?? []).flatMap((dir) => ['--project', dir]),
1461
+ ...(flags.db === undefined ? [] : ['--db', flags.db]),
1462
+ ...(flags['no-db'] === 'true' ? ['--no-db'] : []),
1463
+ ...(flags['no-self'] === 'true' ? ['--no-self'] : []),
1464
+ ...(flags['state-dir'] === undefined ? [] : ['--state-dir', flags['state-dir']]),
1465
+ ];
1466
+ const child = spawn(process.execPath, [process.argv[1], ...argv], {
1467
+ detached: true,
1468
+ stdio: ['ignore', logFd, logFd],
1469
+ });
1470
+ child.unref();
1471
+ // The child writes the lock under its OWN pid; this one is the spawn's
1472
+ // answer, and `smith daemon status` is what confirms the lock exists.
1473
+ printJson({ started: true, pid: child.pid ?? null, dir, log: path.join(dir, 'daemon.log') });
1474
+ return 0;
1475
+ }
1476
+ if (namespace === 'daemon' && action === 'status') {
1477
+ const { DEFAULT_DAEMON_DIR, daemonStatus } = await import('./daemon.js');
1478
+ const dir = flags.dir ?? DEFAULT_DAEMON_DIR;
1479
+ const report = daemonStatus(dir);
1480
+ printJson(report);
1481
+ // Exit 1 when nothing is watching, so a health check is `smith daemon
1482
+ // status >/dev/null` rather than a JSON parse in a shell script. A stale
1483
+ // daemon fails it too: one wedged mid-tick still holds its lock and still
1484
+ // answers `kill -0`, so a probe that only asked `running` reported the
1485
+ // silence as fine -- which is the exact condition a watcher exists to
1486
+ // break. The JSON still separates the two, for a reader that cares which.
1487
+ return report.running && !report.stale ? 0 : 1;
1488
+ }
1489
+ if (namespace === 'daemon' && action === 'stop') {
1490
+ const { DEFAULT_DAEMON_DIR, stopDaemon } = await import('./daemon.js');
1491
+ const dir = flags.dir ?? DEFAULT_DAEMON_DIR;
1492
+ printJson(stopDaemon(dir));
1493
+ return 0;
1494
+ }
1495
+ // What the factory is answerable for, read off the register it already keeps:
1496
+ // `registerProjectInRoadmap` writes a `- project:` bullet for every project
1497
+ // it scaffolds. Without this the `unwatched-project` finding would name a
1498
+ // repo and leave the operator to reconstruct the rest of the list by hand.
1499
+ if (namespace === 'projects' && action === 'list') {
1500
+ const { factoryProjects, missingProjects } = await import('./projects.js');
1501
+ const roadmapOpts = flags.roadmap === undefined ? {} : { roadmapPath: flags.roadmap };
1502
+ const refs = factoryProjects(roadmapOpts);
1503
+ const missing = missingProjects(roadmapOpts);
1504
+ // The flag line is built from `refs` alone, so a declared-and-missing
1505
+ // project can never reach it -- there is no directory to paste.
1506
+ const flagLine = refs.map((ref) => `--project ${ref.dir}`).join(' ');
1507
+ if (flags.json) {
1508
+ printJson({ projects: refs, missing, flags: flagLine });
1509
+ }
1510
+ else {
1511
+ // The flag line last, because it is the line the operator copies, and a
1512
+ // thing to copy is easier to find at the bottom than in the middle.
1513
+ // `?` marks a declared-and-missing project -- distinct from `*` (self)
1514
+ // and the space that marks a resolved project.
1515
+ for (const ref of refs) {
1516
+ process.stdout.write(`${ref.self ? '*' : ' '} ${ref.name}\t${ref.dir}\n`);
1517
+ }
1518
+ for (const m of missing) {
1519
+ process.stdout.write(`? ${m.name}\tnot found under ${m.roots.join(', ')}\n`);
1520
+ }
1521
+ process.stdout.write(`\n${flagLine}\n`);
1522
+ }
1523
+ return 0;
1524
+ }
1525
+ if (namespace === 'worktree' && action === 'create') {
1526
+ const [projectDir, epic, taskId] = requirePositionals(positional, usageFor('worktree create'));
1527
+ printJson(createTaskWorktree(projectDir, epic, taskId));
1528
+ return 0;
1529
+ }
1530
+ if (namespace === 'worktree' && action === 'rm') {
1531
+ const [projectDir, epic, taskId] = requirePositionals(positional, usageFor('worktree rm'));
1532
+ removeTaskWorktree(projectDir, epic, taskId);
1533
+ printJson({ removed: true, epic, taskId });
1534
+ return 0;
1535
+ }
1536
+ // The judge-immutability guard (P9-5): fingerprint before the judge runs,
1537
+ // verify after. Six judge roles are read-only in prose and hold `Bash` in
1538
+ // fact (agent-interviews.md N-10) — this turns the sentence into a check.
1539
+ if (namespace === 'worktree' && action === 'fingerprint') {
1540
+ const [worktreeDir] = requirePositionals(positional, usageFor('worktree fingerprint'));
1541
+ printJson(fingerprintWorktree(worktreeDir));
1542
+ return 0;
1543
+ }
1544
+ if (namespace === 'worktree' && action === 'verify') {
1545
+ // No `required` override any more: `--before <fingerprint.json>` is a flag,
1546
+ // and since P9-21 flags live outside the counted string.
1547
+ const [worktreeDir] = requirePositionals(positional, usageFor('worktree verify'));
1548
+ const before = readJsonFile(requireFlag(flags, 'before'));
1549
+ const result = checkWorktreeImmutable(worktreeDir, before);
1550
+ printJson(result);
1551
+ // Unlike `security triggers`, drift is a violation, not an instruction:
1552
+ // the judge's result is not trustworthy once it moved what it judged.
1553
+ return result.unchanged ? 0 : 1;
1554
+ }
1555
+ if (namespace === 'worktree' && action === 'stale') {
1556
+ const [projectDir, epic] = requirePositionals(positional, usageFor('worktree stale'));
1557
+ printJson(listStale(projectDir, epic));
1558
+ return 0;
1559
+ }
1560
+ // P9-6: the two ends of ingested text. `prompt wrap` fences a payload before
1561
+ // it enters a prompt; `research check` keeps a brief's citations separable
1562
+ // from its recommendation on the way back out.
1563
+ if (namespace === 'prompt' && action === 'wrap') {
1564
+ const [file] = requirePositionals(positional, usageFor('prompt wrap'), 1);
1565
+ const block = wrapIngested({
1566
+ // `-` reads stdin's usual role here: the payload is often a fetch that
1567
+ // was never a file. Kept explicit rather than implicit so a file named
1568
+ // `-` is a caller error, not a silent stdin read.
1569
+ text: file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8'),
1570
+ kind: requireFlag(flags, 'kind'),
1571
+ source: requireFlag(flags, 'source'),
1572
+ });
1573
+ if (flags.json) {
1574
+ printJson(block);
1575
+ }
1576
+ else {
1577
+ // The default output is the block itself, because the caller is composing
1578
+ // a prompt: piping JSON into one would fence the escaping in quotes.
1579
+ process.stdout.write(`${block.text}\n`);
1580
+ }
1581
+ return 0;
1582
+ }
1583
+ // D-142: the writer `user_prompt` never had. Five readers folded the type —
1584
+ // the prompts table, the Decisions lens, the escalation window, the UI's
1585
+ // Prompts filter — and the only way one reached a log was a person hand-
1586
+ // writing `smith event append`, which nobody did. It sits beside `prompt
1587
+ // wrap` because both are about what enters a prompt honestly: wrap labels
1588
+ // borrowed text, record puts the operator's own on the record.
1589
+ if (namespace === 'prompt' && action === 'record') {
1590
+ const [file] = requirePositionals(positional, usageFor('prompt record'), 1);
1591
+ // Same `-` convention as `prompt wrap`, and for a stronger reason: an
1592
+ // operator turn is typed, not filed, so stdin is the ordinary case here
1593
+ // and a heredoc is how it arrives.
1594
+ const text = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
1595
+ const stored = await recordUserPrompt(text, eventContextFromFlags(flags), eventOptsFromFlags(flags));
1596
+ // The event id, not the record: what the caller does next is pass it as
1597
+ // `--causal-parent` to the dispatch this prompt caused, which is the edge
1598
+ // that makes the interleaved timeline (architecture §7) real rather than
1599
+ // inferred from timestamps.
1600
+ printJson({ event_id: stored.event_id, record: stored.record });
1601
+ return 0;
1602
+ }
1603
+ if (namespace === 'research' && action === 'check') {
1604
+ const result = checkBrief(readJsonFile(requireFlag(flags, 'brief')));
1605
+ printJson(result);
1606
+ // A brief whose claims are uncited, or whose recommendation rests on
1607
+ // nothing in it, is a contract violation like a failed claims check —
1608
+ // not a dispatch instruction, so the exit code is the verdict.
1609
+ return result.ok ? 0 : 1;
1610
+ }
1611
+ if (namespace === 'queue' && action === 'run') {
1612
+ const [epic] = requirePositionals(positional, usageFor('queue run'));
1613
+ // Same disease as the positionals, on the flag side, in the one verb that
1614
+ // runs git: `flags.project as string` put an undefined cwd into every
1615
+ // command step() shells out to.
1616
+ const projectDir = requireFlag(flags, 'project');
1617
+ const testCmd = requireFlag(flags, 'test-cmd');
1618
+ const tasksFile = requireFlag(flags, 'tasks');
1619
+ // D-260: the narrowed command is validated here, once, before any git
1620
+ // runs. A template with no `{files}` would otherwise render as the full
1621
+ // suite on every task while the outcome said `selected` — a gate that
1622
+ // lies about its own coverage is worse than a slow one.
1623
+ const selectTestCmd = flags['select-test-cmd'];
1624
+ if (selectTestCmd !== undefined)
1625
+ assertSelectableTestCmd(selectTestCmd);
1626
+ const tasks = readJsonFile(tasksFile);
1627
+ // D-46/P9-29: the queue is the only component that knows a branch landed,
1628
+ // and it used to say so to stdout and nowhere else — which is why the
1629
+ // projector's `completed` column was unreachable by machine. `--session`
1630
+ // is what turns the run into a fact; without it the queue still runs, and
1631
+ // still tells nobody.
1632
+ const events = flags.session
1633
+ ? { ctx: eventContextFromFlags(flags), ...eventOptsFromFlags(flags) }
1634
+ : undefined;
1635
+ // `--tasks` is hand-written, so its ids are whatever was typed. Minting
1636
+ // them from the plan before any git runs is the whole point of P9-29: a
1637
+ // bare id here would put `wave-merged` in the log under a spelling the
1638
+ // plan never used, which is how the dogfood epic folded one task instead
1639
+ // of six. Refuse the run whole rather than merge some and mislabel them.
1640
+ if (events && !flags.plan) {
1641
+ 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 });
1642
+ }
1643
+ if (flags.plan) {
1644
+ const plan = readJsonFile(flags.plan);
1645
+ // …and the plan is not the only register: a follow-up minted by
1646
+ // `findings raise` is in the log alone, and refusing it here is what
1647
+ // left D-41's own follow-up unmergeable (D-48/P9-31).
1648
+ const logged = flags.session
1649
+ ? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
1650
+ : [];
1651
+ const loggedIds = logged.map((t) => t.taskId);
1652
+ for (const task of tasks)
1653
+ task.taskId = resolveTaskId(plan, task.taskId, loggedIds);
1654
+ // D-186: the ids are the plan's now, so the order can be too. `--tasks`
1655
+ // is hand-written, and merging in the order someone typed lets a task
1656
+ // land before the task it declares `depends_on` — the epic's cumulative
1657
+ // gate then runs against an integration branch missing the prerequisite,
1658
+ // and blames the wrong branch when it goes red. `admit()` exists for
1659
+ // exactly this and had no caller until here; a follow-up the plan never
1660
+ // declared carries no edges, so it just sorts by id among its peers.
1661
+ const order = admit(tasks.map((t) => ({ task_id: t.taskId })), plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn })));
1662
+ // Stable: two tasks the plan does not order keep the order admit gave
1663
+ // them, and a duplicated id is still run twice rather than dropped.
1664
+ tasks.sort((a, b) => order.indexOf(a.taskId) - order.indexOf(b.taskId));
1665
+ }
1666
+ const outcomes = [];
1667
+ let allMerged = true;
1668
+ for (const task of tasks) {
1669
+ const outcome = await step(task, {
1670
+ projectDir,
1671
+ epic,
1672
+ testCmd,
1673
+ ...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
1674
+ ...(events ? { events } : {}),
1675
+ });
1676
+ outcomes.push(outcome);
1677
+ if (outcome.outcome !== 'merged') {
1678
+ allMerged = false;
1679
+ break;
1680
+ }
1681
+ }
1682
+ printJson(outcomes);
1683
+ return allMerged ? 0 : 1;
1684
+ }
1685
+ if (namespace === 'queue' && action === 'adopt') {
1686
+ const [typedTaskId] = requirePositionals(positional, usageFor('queue adopt'));
1687
+ const projectDir = requireFlag(flags, 'project');
1688
+ const mergeCommit = requireFlag(flags, 'merge-commit');
1689
+ // Every flag here is required, unlike `queue run`'s optional session: this
1690
+ // verb's entire output is one `wave-merged`, so a run without a session to
1691
+ // write it into has done nothing, and a run without a plan would write it
1692
+ // under whatever id was typed — the D-46/P9-29 mislabelling, reintroduced
1693
+ // by the command that exists to repair its aftermath.
1694
+ const plan = readJsonFile(requireFlag(flags, 'plan'));
1695
+ const sessionId = requireFlag(flags, 'session');
1696
+ const logged = await readAddedTasks({ sessionId }, eventOptsFromFlags(flags));
1697
+ const taskId = resolveTaskId(plan, typedTaskId, logged.map((t) => t.taskId));
1698
+ const outcome = await adopt(
1699
+ // The branch is derived, never typed. A `--branch` override would let the
1700
+ // operator hand this the OTHER task's branch — which really is a parent of
1701
+ // the merge — and adopt any task with it, which is the forgery the whole
1702
+ // verb exists to prevent. Deriving it from the plan-resolved id means the
1703
+ // branch and the id it is logged under cannot disagree.
1704
+ { taskId, branch: taskBranchName(plan.epic_id, taskId) }, {
1705
+ projectDir,
1706
+ epic: plan.epic_id,
1707
+ mergeCommit,
1708
+ events: { ctx: eventContextFromFlags(flags), ...eventOptsFromFlags(flags) },
1709
+ });
1710
+ printJson(outcome);
1711
+ return 0;
1712
+ }
1713
+ if (namespace === 'session' && action === 'start') {
1714
+ const [sessionId] = requirePositionals(positional, usageFor('session start'));
1715
+ // Every other write verb takes `--session` and `--causal-parent` and can
1716
+ // therefore assume a log. This is the one that cannot, so it is the one
1717
+ // command in the CLI whose whole argument list is what it is about.
1718
+ printJson(await startSession(sessionId, {
1719
+ ...eventOptsFromFlags(flags),
1720
+ ...(flags.actor === undefined ? {} : { actor: flags.actor }),
1721
+ ...(flags.continues === undefined ? {} : { continues: flags.continues }),
1722
+ }));
1723
+ return 0;
1724
+ }
1725
+ if (namespace === 'event' && action === 'append') {
1726
+ const [json] = requirePositionals(positional, usageFor('event append'));
1727
+ const input = JSON.parse(json);
1728
+ const result = await appendEvent(input, eventOptsFromFlags(flags));
1729
+ // D-163. `event_type` is open here and closed in timeline(): the schema
1730
+ // keeps the write side free so an unknown type is never rejected and lost,
1731
+ // and the read side filters to timelineEventTypes() so the operator's
1732
+ // screen stays a timeline rather than a firehose. Both are deliberate.
1733
+ // What was missing is any word to the writer that they had landed on the
1734
+ // far side of that line — this command accepted nineteen improvised types
1735
+ // from the factory's own operator skill, receipted every one as success,
1736
+ // and none of the 25 records ever reached the screen. Exit stays 0: the
1737
+ // write worked, and refusing it is precisely what the open side prevents.
1738
+ // Loaded here rather than at the top of the branch, for the reason `ui
1739
+ // serve` states below: the append is the command, and a malformed payload
1740
+ // should cost a message rather than the module loading it never needed.
1741
+ // `timelineEventTypes()` reads the taxonomy and no database, but it lives
1742
+ // beside the queries that do.
1743
+ const { timelineEventTypes } = await import('./db/queries.js');
1744
+ const onTimeline = timelineEventTypes().includes(result.record.event_type);
1745
+ if (!onTimeline) {
1746
+ process.stderr.write(`warning: event_type "${result.record.event_type}" is not read by the operator timeline. ` +
1747
+ `${result.event_id} is written and durable, but timeline() filters it out under every ` +
1748
+ 'filter. Use a gate_event/graph_event value from factory/policies/taxonomy.yml, or add ' +
1749
+ 'the type to FREE_TIMELINE_EVENT_TYPES in factory/orchestrator/src/db/queries.ts.\n');
1750
+ }
1751
+ // D-245. The same shape of receipt, for the other half of the record the
1752
+ // writer can get silently wrong. `task_id` is read at the top level, beside
1753
+ // `session_id`; SKILL.md used to list the dispatch payload's fields and say
1754
+ // nothing about where the task id went, so 29 hand-written dispatches
1755
+ // across two dogfood sessions put it inside `payload` instead. Every
1756
+ // reader now takes it from either level, but only the top-level field is a
1757
+ // column, so a payload-only id answers no task-scoped query typed against
1758
+ // the DB. Exit stays 0: the write is valid and durable, and the id is not
1759
+ // lost -- it is just in the slower place.
1760
+ const payloadTaskId = result.record.payload?.task_id;
1761
+ const envelopeTaskId = result.record.task_id;
1762
+ if (typeof payloadTaskId === 'string' &&
1763
+ payloadTaskId.length > 0 &&
1764
+ (typeof envelopeTaskId !== 'string' || envelopeTaskId.length === 0)) {
1765
+ process.stderr.write(`warning: "${payloadTaskId}" names a task from inside the payload, and the event's own ` +
1766
+ `task_id is empty. ${result.event_id} is written and durable, and the folds read both ` +
1767
+ 'levels, but only the top-level field is indexed. Put task_id beside session_id, at the ' +
1768
+ 'top level of the JSON.\n');
1769
+ }
1770
+ // D-261. The third of these receipts, for the field the two above do not
1771
+ // cover: a log has one root, and this command is structurally unable to be
1772
+ // what enforces that. `causal_parent: null` on a `session-start` is exactly
1773
+ // what the writer's rule permits, so a second one into a log that already
1774
+ // has a root passes every check and comes back as a success -- and no
1775
+ // reader ever looks at it. `sessionLineage` takes the FIRST root, and the
1776
+ // tree-of-sessions reading the cross-session edge rests on assumes one
1777
+ // entry point per session. Exit stays 0 for the same reason it does above:
1778
+ // the record is valid and durable, and refusing it is not this side's job.
1779
+ // `smith session start` is the side that can refuse.
1780
+ if (result.record.event_type === 'session-start' &&
1781
+ result.event_id !== `${result.record.session_id}#0`) {
1782
+ process.stderr.write(`warning: this session-start is ${result.event_id}, not ${result.record.session_id}#0 — ` +
1783
+ `session "${result.record.session_id}" was already open. The record is written and ` +
1784
+ 'durable, but a log has one root and readers take the first, so nothing will read this ' +
1785
+ 'one. Use `smith session start`, which refuses instead of receipting, and chain off the ' +
1786
+ 'event that is already there.\n');
1787
+ }
1788
+ printJson({ ...result, on_timeline: onTimeline });
1789
+ return 0;
1790
+ }
1791
+ if (namespace === 'event' && action === 'tail') {
1792
+ const [sessionId] = requirePositionals(positional, usageFor('event tail'));
1793
+ const n = boundedIntFlag(flags, 'n', { min: 1 }) ?? 20;
1794
+ const opts = eventOptsFromFlags(flags);
1795
+ // P9-28: and the id has to name a session that exists. This is the verb an
1796
+ // operator reaches for when they are not sure what the log holds, so "your
1797
+ // session is empty" was the one wrong answer it could give to a typo.
1798
+ requireSession(sessionId, opts);
1799
+ // P9-7: --lineage reads the epic rather than the session. An epic split
1800
+ // across operator sessions has its recent history in whichever session ran
1801
+ // last, and the plain tail would show only that one.
1802
+ //
1803
+ // Through readLineageEvents since D-119, which merges the logs by `ts`
1804
+ // rather than concatenating them root-first. On a tail that matters: the
1805
+ // last n events of a concatenation are the last n of the LAST session, so
1806
+ // an operator resuming an epic saw its newest events padded with nothing
1807
+ // from before the split. Now `--lineage` tails the epic in time order.
1808
+ let events = flags.lineage
1809
+ ? (await readLineageEvents(sessionId, opts)).slice(-n)
1810
+ : await tailEvents(sessionId, n, opts);
1811
+ if (flags.task)
1812
+ events = filterEvents(events, { taskId: flags.task });
1813
+ printJson(events);
1814
+ return 0;
1815
+ }
1816
+ if (namespace === 'event' && action === 'lineage') {
1817
+ const [sessionId] = requirePositionals(positional, usageFor('event lineage'));
1818
+ const tree = await sessionTree(sessionId, eventOptsFromFlags(flags));
1819
+ // D-266. `lineage` is every session a lineage-wide fold reads, because a
1820
+ // display verb that draws a narrower scope than the gates decide on is the
1821
+ // reason the operator trusts the wrong picture. `depth` stays the length of
1822
+ // the ancestry alone -- it has always meant "how many windows back does
1823
+ // this go", and a fan-out is not depth. `continued_by` is the half that is
1824
+ // new, and printing it as its own field is what makes the two readable
1825
+ // apart: lineage === [...ancestry, ...continued_by].
1826
+ printJson({
1827
+ session: sessionId,
1828
+ lineage: tree.sessions,
1829
+ depth: tree.ancestry.length,
1830
+ root: tree.ancestry[0],
1831
+ continued_by: tree.continuations,
1832
+ });
1833
+ return 0;
1834
+ }
1835
+ if (namespace === 'dispatch' && action === 'check') {
1836
+ // P9-23: crosscheck.yml's finder_ne_critic, asserted against the log
1837
+ // instead of trusted. Fail-closed — `unverifiable` exits 1 exactly like a
1838
+ // violation, because a check that cannot answer must not read as a pass;
1839
+ // see dispatchAudit.ts for why each status means what it means.
1840
+ // required: 1 — `<id>` and `<path>` in the usage line are flag values,
1841
+ // and requirePositionals reads every `<placeholder>` positionally.
1842
+ const [sessionId] = requirePositionals(positional, usageFor('dispatch check'), 1);
1843
+ requireSession(sessionId, eventOptsFromFlags(flags));
1844
+ // Lineage-wide (D-119): a finder dispatched before the session split and
1845
+ // its critic after it is precisely the pairing this check exists to catch,
1846
+ // and one session's log holds half of it.
1847
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
1848
+ const pairs = loadCrosscheckPolicy(flags.policy).asymmetricRoles.pairs;
1849
+ const report = checkDispatchAsymmetry(events, pairs, {
1850
+ sessionId,
1851
+ ...(flags.task ? { taskId: flags.task } : {}),
1852
+ });
1853
+ printJson(report);
1854
+ return report.ok ? 0 : 1;
1855
+ }
1856
+ if (namespace === 'tester' && action === 'check') {
1857
+ // The other half of `dispatch check`: that one asks whether the critic ran
1858
+ // on the finder's model, this one asks whether a tester ran at all, in a
1859
+ // turn of its own, before the gate that graded its tests. Same fail-closed
1860
+ // contract — `unverifiable` exits 1 — and testerAudit.ts says why absence
1861
+ // is a violation here where it is `not-applicable` there.
1862
+ // required: 1 — `<id>` and `<path>` in the usage line are flag values.
1863
+ const [sessionId] = requirePositionals(positional, usageFor('tester check'), 1);
1864
+ requireSession(sessionId, eventOptsFromFlags(flags));
1865
+ // Lineage-wide (D-119), for `dispatch check`'s reason: a coder dispatched
1866
+ // before a session split and its tester after it is exactly the pairing
1867
+ // this check reads, and one session's log holds half of it.
1868
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
1869
+ const pairs = loadCrosscheckPolicy(flags.policy).roleIsolation.pairs;
1870
+ const report = checkTesterIsolation(events, pairs, {
1871
+ sessionId,
1872
+ ...(flags.task ? { taskId: flags.task } : {}),
1873
+ });
1874
+ printJson(report);
1875
+ return report.ok ? 0 : 1;
1876
+ }
1877
+ if (namespace === 'delegation' && action === 'check') {
1878
+ // D13 step 3: the other two checks read a dispatch as evidence that a
1879
+ // second turn happened, and that reading only holds while the dispatching
1880
+ // node owns the log it writes into. This verb asserts the condition. It is
1881
+ // two questions in one report because they fail apart: the topology can be
1882
+ // unsound while the run obeyed it, and a sound topology can be disobeyed.
1883
+ // required: 1 -- `<id>` and `<path>` in the usage line are flag values.
1884
+ const [sessionId] = requirePositionals(positional, usageFor('delegation check'), 1);
1885
+ requireSession(sessionId, eventOptsFromFlags(flags));
1886
+ // Lineage-wide (D-119), and here it is the whole point: a wave-runner's
1887
+ // own session is where its dispatches land, so a check that read only the
1888
+ // epic's session would see the delegation it exists to audit as absent.
1889
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
1890
+ const policy = loadDelegationPolicy(flags.policy);
1891
+ const crosscheck = loadCrosscheckPolicy(flags.crosscheck);
1892
+ const grants = checkDelegationGrants(policy, {
1893
+ agentRoles: loadTaxonomy().dimensions.agent ?? [],
1894
+ isolationPairs: crosscheck.roleIsolation.pairs,
1895
+ asymmetricPairs: crosscheck.asymmetricRoles.pairs,
1896
+ });
1897
+ const log = checkDelegationLog(events, policy, {
1898
+ sessionId,
1899
+ ...(flags.task ? { taskId: flags.task } : {}),
1900
+ });
1901
+ printJson({ session: sessionId, grants, log, ok: grants.ok && log.ok });
1902
+ // Same fail-closed contract as `dispatch check`: `unverifiable` exits 1,
1903
+ // because a wave-runner that has not opened its log yet is not a pass.
1904
+ return grants.ok && log.ok ? 0 : 1;
1905
+ }
1906
+ if (namespace === 'escalation' && action === 'check') {
1907
+ // P9-32: budgets.yml's escalation_ladder, asserted against the log instead
1908
+ // of trusted. Same fail-closed contract as `dispatch check` — see
1909
+ // escalation.ts, including what the rung-3 check does and does not claim.
1910
+ // required: 1 — `<id>` and `<path>` in the usage line are flag values.
1911
+ const [sessionId] = requirePositionals(positional, usageFor('escalation check'), 1);
1912
+ requireSession(sessionId, eventOptsFromFlags(flags));
1913
+ // Lineage-wide (D-119), for `dispatch check`'s reason: a ladder is climbed
1914
+ // rung by rung over an epic, and reading one session shows a run that
1915
+ // started at rung 2 as one that skipped rung 1.
1916
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
1917
+ const ladder = loadBudgetPolicy(flags.policy).escalationLadder;
1918
+ const report = checkEscalationLadder(events, ladder, {
1919
+ sessionId,
1920
+ ...(flags.task ? { taskId: flags.task } : {}),
1921
+ });
1922
+ printJson(report);
1923
+ return report.ok ? 0 : 1;
1924
+ }
1925
+ if (namespace === 'claims' && action === 'check') {
1926
+ // Two populations, one classifier (P9-3). `--roots` is the write-root
1927
+ // mode: a role that works outside a worktree — the planner
1928
+ // (factory/specs/active/<epic-id>/**) and the scribe (state/lessons/**) —
1929
+ // has no claims to read from a spec, no task branch to diff, and hands its
1930
+ // output back uncommitted. `--since <ref>` widens the window to what the
1931
+ // role committed itself; the planner holds Bash, so it can.
1932
+ const roots = repeated.roots;
1933
+ if (roots !== undefined) {
1934
+ const [rootDir] = requirePositionals(positional, usageFor('claims check --roots'));
1935
+ const result = writeRootCheck(rootDir, roots, flags.since ? { since: flags.since } : {});
1936
+ printJson(result);
1937
+ return result.violation ? 1 : 0;
1938
+ }
1939
+ const [worktreeDir, specFile] = requirePositionals(positional, usageFor('claims check spec'));
1940
+ const spec = readJsonFile(specFile);
1941
+ const result = postRunCheck(worktreeDir, spec.claims);
1942
+ printJson(result);
1943
+ return result.violation ? 1 : 0;
1944
+ }
1945
+ if (namespace === 'claims' && action === 'impact') {
1946
+ // The blind spot a path claim has by construction (P9-3). `claims check`
1947
+ // compares two lists of globs; this compares the edges between the files
1948
+ // those globs match, which is where "task A changed parse(), task B calls
1949
+ // it" actually lives. Two forms, and the difference is what they can
1950
+ // prove — see impact.ts's header.
1951
+ if (flags.plan !== undefined) {
1952
+ const plan = readJsonFile(flags.plan);
1953
+ // Same refusal as `wave check`, for the same reason: validating the
1954
+ // empty set answers "admissible" about a wave nobody described.
1955
+ if (positional.length === 0) {
1956
+ 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 });
1957
+ }
1958
+ const claimsById = new Map(plan.tasks.map((task) => [task.task_id, task.claims]));
1959
+ const tasks = positional.map((typed) => {
1960
+ const id = resolveTaskId(plan, typed);
1961
+ const claims = claimsById.get(id);
1962
+ if (!Array.isArray(claims) || claims.some((claim) => typeof claim !== 'string')) {
1963
+ throw new SmithError('claims.unreadable-claims', `Task "${id}" does not declare its claims as a list of globs.`, { task_id: id, received: claims === undefined ? 'undefined' : typeof claims });
1964
+ }
1965
+ return { task_id: id, claims };
1966
+ });
1967
+ // The declarations are read off the checkout, not off the plan: a claim
1968
+ // says which files a task may write, and only the tree says what those
1969
+ // files import today.
1970
+ const graph = buildSymbolGraph(collectSources(flags.repo ?? REPO_ROOT));
1971
+ const report = waveImpact(graph, tasks);
1972
+ printJson(report);
1973
+ return report.ok ? 0 : 1;
1974
+ }
1975
+ const [worktreeDir, specFile] = requirePositionals(positional, usageFor('claims impact spec'));
1976
+ const spec = readJsonFile(specFile);
1977
+ // The worktree is a full checkout, so it is both halves of the question:
1978
+ // the diff this task committed, and everyone in the repo who imports it.
1979
+ const diffs = collectExportDiffs(worktreeDir, collectCommittedChanges(worktreeDir));
1980
+ const graph = buildSymbolGraph(collectSources(worktreeDir));
1981
+ const report = exportImpact(graph, diffs, spec.claims);
1982
+ printJson(report);
1983
+ return report.ok ? 0 : 1;
1984
+ }
1985
+ if (namespace === 'effort' && action === 'show') {
1986
+ // How much judgment this epic buys, computed instead of remembered — the
1987
+ // same "ask, do not recall" contract `security triggers` above has. Always
1988
+ // exits 0 when it can answer: a tier is a plan for the run, not a verdict
1989
+ // on it. `--effort` answers for a tier the plan does not carry yet, which
1990
+ // is the case `/bs plan` is in when it picks one.
1991
+ const policy = loadEffortPolicy(flags.policy);
1992
+ const securityPolicy = loadCrosscheckPolicy(flags.crosscheck).planQuorum;
1993
+ const planFile = flags.plan;
1994
+ printJson(resolveEffort(policy, securityPolicy, {
1995
+ ...(planFile ? { plan: readJsonFile(planFile) } : {}),
1996
+ ...(flags.effort !== undefined ? { override: flags.effort } : {}),
1997
+ }));
1998
+ return 0;
1999
+ }
2000
+ if (namespace === 'security' && action === 'triggers') {
2001
+ // The security-reviewer's dispatch condition, computed instead of
2002
+ // remembered (P9-4). Always exits 0 when it can answer: a fired trigger is
2003
+ // a dispatch instruction, not a violation — read `dispatchSecurityReviewer`.
2004
+ const spec = readJsonFile(requireFlag(flags, 'task'));
2005
+ const result = securityTriggers(spec, loadSensitivePathsPolicy(flags.policy), {
2006
+ case: flags.case,
2007
+ epicTags: repeated['epic-tag'],
2008
+ scheduledRecheck: flags.recheck === 'true',
2009
+ });
2010
+ printJson(result);
2011
+ return 0;
2012
+ }
2013
+ // The judge sandbox (A3). `worktree fingerprint`/`verify` above detect a
2014
+ // judge that wrote; these three refuse it up front. The orchestrator opens a
2015
+ // lease before handing a worktree to a judge and closes it when the verdict
2016
+ // is in; while one is open, `policy hook` adds guardrails.yml's three
2017
+ // `judge-*` rules to the six that always apply. The judge itself is refused
2018
+ // all three of these verbs by `judge-sandbox-escape` — they are the
2019
+ // orchestrator's, which is the only reason the lease means anything.
2020
+ if (namespace === 'sandbox' && action === 'open') {
2021
+ const [worktreeDir] = requirePositionals(positional, usageFor('sandbox open'));
2022
+ const lease = openSandbox({
2023
+ worktreeDir,
2024
+ role: requireFlag(flags, 'role'),
2025
+ taskId: requireFlag(flags, 'task'),
2026
+ sessionId: requireFlag(flags, 'session'),
2027
+ // The clock is read here rather than inside sandbox.ts so the module
2028
+ // stays pinnable by a test — the same reason `openedAt` is an input.
2029
+ openedAt: flags.at ?? new Date().toISOString(),
2030
+ }, flags['lease-dir'] ?? SANDBOX_LEASE_DIR);
2031
+ printJson(lease);
2032
+ return 0;
2033
+ }
2034
+ if (namespace === 'sandbox' && action === 'close') {
2035
+ // Exit 0 whether or not a lease was there. Closing is the orchestrator's
2036
+ // cleanup path, and it runs after a judge that crashed just as much as
2037
+ // after one that finished; making "already closed" an error would turn
2038
+ // tidying up into a second failure to handle.
2039
+ const [worktreeDir] = requirePositionals(positional, usageFor('sandbox close'));
2040
+ const closed = closeSandbox(worktreeDir, flags['lease-dir'] ?? SANDBOX_LEASE_DIR);
2041
+ printJson({ closed, worktreeDir });
2042
+ return 0;
2043
+ }
2044
+ if (namespace === 'sandbox' && action === 'status') {
2045
+ // With --worktree: the lease that would bind a command run there, which is
2046
+ // the question the hook asks. Without: every open lease, which is the
2047
+ // question an operator asks when a judge is stuck or a wave died holding
2048
+ // one.
2049
+ const leaseDir = flags['lease-dir'] ?? SANDBOX_LEASE_DIR;
2050
+ if (flags.worktree) {
2051
+ const lease = activeSandboxFor(flags.worktree, leaseDir);
2052
+ printJson({ worktree: flags.worktree, sandbox: lease });
2053
+ return 0;
2054
+ }
2055
+ printJson({ sandboxes: listSandboxes(leaseDir) });
2056
+ return 0;
2057
+ }
2058
+ if (namespace === 'stack' && (action === 'show' || action === 'check')) {
2059
+ // The install interview's answers, read back (`show`) and priced against
2060
+ // what factory/scaffold/ can actually build (`check`).
2061
+ //
2062
+ // `check` exits 1 only on a `refused` answer — one `smith new` will stop
2063
+ // on. A `recorded` mismatch is deliberately green: an operator whose stack
2064
+ // is wider than the template tree has not misconfigured anything, and a
2065
+ // check that went red for them would be a check they learned to ignore.
2066
+ const answers = loadStackAnswers(flags.policy);
2067
+ if (action === 'show') {
2068
+ printJson(answers);
2069
+ return 0;
2070
+ }
2071
+ const report = checkStack(answers);
2072
+ printJson(report);
2073
+ return report.ok ? 0 : 1;
2074
+ }
2075
+ if (namespace === 'policy' && action === 'check') {
2076
+ // Dry run of the guard hook (`policy hook`, below) against one command
2077
+ // line instead of a live PreToolUse payload — "would this be blocked?"
2078
+ // before an agent or operator actually runs it. Branch/repoRoot default
2079
+ // to what the checkout actually is, same as the hook itself resolves
2080
+ // them, so a caller only needs --branch/--tool to ask a hypothetical
2081
+ // ("what if I were on main") rather than the real one.
2082
+ // --command stays required even for a file tool, because the answer to
2083
+ // "what would the hook say" depends on both halves of the payload and a
2084
+ // caller who omits one is usually asking the wrong question. `--command
2085
+ // ''` is the file-tool form: nothing to run, a path to check.
2086
+ const command = requireFlag(flags, 'command');
2087
+ const toolName = flags.tool ?? 'Bash';
2088
+ // The other half of a PreToolUse payload: `tool_input.file_path`, which
2089
+ // is what a `Write`/`Edit` call carries instead of a command. Only the
2090
+ // role write scopes read it today — see policy.ts's PolicyContext.
2091
+ const filePath = flags.file ?? null;
2092
+ // Resolved from the caller's cwd, not from the checkout this binary was
2093
+ // built in: an operator asking "would this be denied?" means from where
2094
+ // they are standing, and in this repo that is routinely a worktree on a
2095
+ // different branch than the main clone. Same reason as `policy hook`
2096
+ // below, which has the stricter version of the problem.
2097
+ const repoRoot = detectRepoRoot(process.cwd());
2098
+ const branch = flags.branch ?? detectCurrentBranch(process.cwd());
2099
+ // Same shape as --branch: without it, the real lease over the caller's cwd,
2100
+ // so `policy check` answers what the hook would actually answer from here.
2101
+ // With it, a hypothetical — "what would a reviewer session be refused?" —
2102
+ // which is how an operator reads the judge rules without having to stage a
2103
+ // judge to read them.
2104
+ const sandbox = flags.sandbox
2105
+ ? {
2106
+ worktreeDir: repoRoot ?? process.cwd(),
2107
+ role: flags.sandbox,
2108
+ taskId: '(hypothetical)',
2109
+ sessionId: '(hypothetical)',
2110
+ openedAt: new Date().toISOString(),
2111
+ }
2112
+ : activeSandboxFor(process.cwd(), flags['lease-dir'] ?? SANDBOX_LEASE_DIR);
2113
+ const decision = evaluateCommand({ toolName, command, branch, repoRoot, sandbox, filePath }, loadGuardrailPolicy());
2114
+ printJson(decision);
2115
+ return decision.allowed ? 0 : 1;
2116
+ }
2117
+ if (namespace === 'policy' && action === 'hook') {
2118
+ // The PreToolUse hook body, kept as a command because guard.sh's header,
2119
+ // the docs and cli.test.ts all name it — but the shim no longer execs it.
2120
+ // It execs `dist/policyHook.js`, an entry point over the same function
2121
+ // whose import graph is the decision's alone; this router carries 64
2122
+ // top-level imports, ~1.3s of them the database layer, in front of ~39ms
2123
+ // of policy work, and the hook pays that on every guarded tool call. Both
2124
+ // paths call decideHookPayload, so a second copy cannot drift out of
2125
+ // agreement with the one actually guarding the repo.
2126
+ //
2127
+ // The contract, which policyHook.ts mirrors exactly: exit 0 with the deny
2128
+ // envelope on stdout, exit 0 in silence for an allow, and let a malformed
2129
+ // payload throw out through main()'s catch for a non-zero exit. See
2130
+ // hookDecision.ts for why each of those three is what it is.
2131
+ const output = decideHookPayload(readFileSync(0, 'utf8'), process.cwd());
2132
+ if (output)
2133
+ printJson(output);
2134
+ return 0;
2135
+ }
2136
+ if (namespace === 'gate' && action === 'run') {
2137
+ const { runGate } = await import('./gate.js');
2138
+ const [taskId] = requirePositionals(positional, usageFor('gate run'));
2139
+ const worktreeDir = requireFlag(flags, 'worktree');
2140
+ const checks = readJsonFile(requireFlag(flags, 'checks'));
2141
+ // The result file has the same two intake shapes as findings below, and for
2142
+ // the same reason. With `--agent`, `--result` is the worker's half —
2143
+ // run_status/structured_output/artifacts — and the dispatcher stamps the
2144
+ // five fields it owns, token_usage included: an agent cannot read its own
2145
+ // meter, so a token count it writes is invented (D-18/P9-17). Without
2146
+ // `--agent` the file is taken as a complete document, which is what a
2147
+ // replay or a fixture hands over.
2148
+ const resultFile = readJsonFile(requireFlag(flags, 'result'));
2149
+ const result = flags.agent
2150
+ ? stampResultEnvelope(resultFile, {
2151
+ taskId,
2152
+ agent: flags.agent,
2153
+ provider: requireFlag(flags, 'provider'),
2154
+ modelTier: requireFlag(flags, 'model-tier'),
2155
+ inputTokens: requireIntFlag(flags, 'input-tokens'),
2156
+ outputTokens: requireIntFlag(flags, 'output-tokens'),
2157
+ })
2158
+ : resultFile;
2159
+ // Two intake shapes. `--evidence` is what judges actually produce
2160
+ // (interview N-2): evidence only, with the orchestrator minting
2161
+ // finding_id/task_id/found_by/finding_status here. It repeats, once per
2162
+ // judge at this gate (D-32/P9-13). `--findings` stays for already-minted
2163
+ // records (replays, fixtures, cross-check re-runs).
2164
+ const findingsInput = [
2165
+ ...(flags.findings ? readJsonFile(flags.findings) : []),
2166
+ ...mintFromEvidence(args, taskId),
2167
+ ];
2168
+ const lessons = flags.lessons ? parseLessons(readFileSync(flags.lessons, 'utf8')) : [];
2169
+ const ctx = eventContextFromFlags(flags);
2170
+ // --plan is what lets the gate answer "whose finding is this" from the
2171
+ // file rather than from who happened to be at the gate (D-41/P9-24).
2172
+ // Optional: a gate run without a plan keeps every finding on --task, which
2173
+ // is the pre-P9-24 behaviour, not a silent misattribution.
2174
+ const ownership = ownershipFromFlags(flags);
2175
+ // P9-18: the same file also carries what the task said it would cost, so
2176
+ // the gate can compare the declaration against the measurement. `--base`
2177
+ // is for callers that know the exact base they branched from (the merge
2178
+ // queue does); without it the diff is measured against the integration
2179
+ // branch the task-branch name implies.
2180
+ const budget = budgetFromFlags(flags, taskId);
2181
+ // P9-11: handing the gate a judge's evidence IS that judge reporting.
2182
+ // Doing it here means the common path — dispatch, judge writes its file,
2183
+ // `gate run --evidence` — closes the turn in one command instead of two,
2184
+ // and forgetting the second one can no longer block a task whose judge
2185
+ // did everything right. A `--found-by` role with no dispatch behind it
2186
+ // has no turn to close and takes the pre-P9-11 path untouched.
2187
+ //
2188
+ // D-158: the turns to close are the paired sources, not `flags.evidence`
2189
+ // and `flags['found-by']`. Those are last-occurrence-wins, so a gate run
2190
+ // carrying two judges' files — the shape D-32/P9-13 taught the minting
2191
+ // path — closed the second judge's turn and left the first outstanding,
2192
+ // blocking the gate on a judge that had just handed in its evidence. One
2193
+ // close per role: a judge that splits its findings across two files still
2194
+ // owes one turn, and a second report against it would be a duplicate.
2195
+ const evidenceGiven = evidenceSources(args);
2196
+ if (evidenceGiven.length > 0) {
2197
+ const turns = await readJudgeTurns(taskId, ctx, eventOptsFromFlags(flags));
2198
+ const closed = new Set();
2199
+ for (const { foundBy, file } of evidenceGiven) {
2200
+ if (closed.has(foundBy))
2201
+ continue;
2202
+ if (!turns.some((t) => t.role === foundBy && !t.reported))
2203
+ continue;
2204
+ closed.add(foundBy);
2205
+ await recordJudgeReport({ taskId, role: foundBy, artifactPath: file }, ctx, eventOptsFromFlags(flags));
2206
+ }
2207
+ }
2208
+ // P9-11: the genuinely clean case, said out loud. A judge that found
2209
+ // nothing writes `[]` and reports through `smith judge report`; this flag
2210
+ // is for the operator who ran one outside the factory, and it records an
2211
+ // attestation as an attestation — artifact_path null, attested_by
2212
+ // operator — rather than dressing it up as a file that was never written.
2213
+ for (const role of repeated['no-findings'] ?? []) {
2214
+ // `parseArgs` renders a valueless flag as the string 'true'. Attesting a
2215
+ // role called "true" would close nothing (no such dispatch exists) and
2216
+ // say so nowhere, so a bare --no-findings is a usage error instead.
2217
+ if (role === 'true') {
2218
+ 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>' });
2219
+ }
2220
+ await recordJudgeReport({ taskId, role, noFindings: true }, ctx, eventOptsFromFlags(flags));
2221
+ }
2222
+ // --grader is the grader's own result file (state/results/<task-id>
2223
+ // .grader-r<round>.json). Optional for the same reason as --plan: an ad-hoc
2224
+ // gate run has no rubric result to hand over, and inventing one would be
2225
+ // worse than skipping the stage (D-34/P9-14).
2226
+ const graderVerdict = flags.grader ? readJsonFile(flags.grader) : undefined;
2227
+ const outcome = await runGate({
2228
+ taskId,
2229
+ result,
2230
+ worktreeDir,
2231
+ checks,
2232
+ findingsInput,
2233
+ lessons,
2234
+ runAll: flags['run-all'] === 'true',
2235
+ ...(ownership ? { ownership } : {}),
2236
+ // --base is the ref the queue would merge into, normally
2237
+ // smith/<epic>/integration. One flag, two readers: the commit
2238
+ // certificate asks whether the branch carries commits the base does
2239
+ // not (D-30/P9-8), the budget check measures the diff against it
2240
+ // (P9-18). Optional like --plan — without it the gate still refuses an
2241
+ // uncommitted worktree and still measures the diff, it just falls back
2242
+ // to the base the task-branch name implies.
2243
+ ...(flags.base ? { baseRef: flags.base } : {}),
2244
+ ...(graderVerdict !== undefined ? { graderVerdict } : {}),
2245
+ ...(budget ? { budget } : {}),
2246
+ ...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
2247
+ }, ctx, eventOptsFromFlags(flags));
2248
+ printJson(outcome);
2249
+ return outcome.outcome === 'blocked' ? 1 : 0;
2250
+ }
2251
+ // D-40/P9-25: the gate's coverage evidence, without staging a gate run.
2252
+ // This is the verb the D-40 investigation wanted and did not have — it took
2253
+ // a coverage re-run on the pre-task-4 integration branch to establish what
2254
+ // one lookup in coverage-summary.json says outright.
2255
+ if (namespace === 'coverage' && action === 'check') {
2256
+ const [worktreeDir] = requirePositionals(positional, usageFor('coverage check'));
2257
+ // Whose claims to judge is a question with no safe default: judging the
2258
+ // whole plan's claims at one task's gate is D-41 again, and judging none
2259
+ // silently would report `complete: true` on no evidence at all. So --plan
2260
+ // demands --task, and neither means "the total, and no subjects".
2261
+ const claims = flags.plan ? claimsForDispatch(flags) : [];
2262
+ const evidence = await collectCoverageEvidence({
2263
+ worktreeDir,
2264
+ claims,
2265
+ ...(flags.summary ? { summaryPath: flags.summary } : {}),
2266
+ });
2267
+ printJson({
2268
+ summary_path: evidence.summaryPath,
2269
+ present: evidence.present,
2270
+ complete: evidence.complete,
2271
+ files_measured: evidence.filesMeasured,
2272
+ total: evidence.total,
2273
+ subjects: evidence.subjects.map((s) => ({
2274
+ path: s.path,
2275
+ status: s.status,
2276
+ lines_pct: s.coverage?.lines.pct ?? null,
2277
+ statements_pct: s.coverage?.statements.pct ?? null,
2278
+ functions_pct: s.coverage?.functions.pct ?? null,
2279
+ branches_pct: s.coverage?.branches.pct ?? null,
2280
+ })),
2281
+ detail: evidence.detail,
2282
+ });
2283
+ return evidence.complete ? 0 : 1;
2284
+ }
2285
+ // P9-33: the consumer `epic.alarm_ratio` never had. Read-only over the log —
2286
+ // it reports where spend stands, it does not stop anything. Exit 1 on a
2287
+ // crossing OR on a record too holey to prove there wasn't one.
2288
+ if (namespace === 'budget' && action === 'alarm') {
2289
+ // required: 1 — `<id>` and `<path>` in the usage line are flag values, and
2290
+ // requirePositionals reads every `<placeholder>` positionally.
2291
+ const [sessionId] = requirePositionals(positional, usageFor('budget alarm'), 1);
2292
+ requireSession(sessionId, eventOptsFromFlags(flags));
2293
+ // Lineage-wide (D-119). Spend is an epic's, not a session's: reading one
2294
+ // session reports half an epic's cost as the whole of it, and reports it
2295
+ // under an alarm threshold it may already have crossed.
2296
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
2297
+ const report = checkBudgetAlarm(events, loadBudgetPolicy(flags.policy), {
2298
+ sessionId,
2299
+ ...(flags.epic ? { epicId: flags.epic } : {}),
2300
+ });
2301
+ printJson(report);
2302
+ return report.ok ? 0 : 1;
2303
+ }
2304
+ // The consumer architecture §9.7's "monotonically decreasing same-mistake
2305
+ // rate" never had. Read-only over the log, like `budget alarm` — and like it,
2306
+ // exit 1 both on a rate that rose and on a record that cannot show it didn't.
2307
+ // `--lessons` defaults to the committed corpus because the corpus IS half the
2308
+ // measurement: a rate of zero against lessons that can escalate nothing is a
2309
+ // fact about the corpus, not about the work.
2310
+ if (namespace === 'kpi' && action === 'same-mistake') {
2311
+ const [sessionId] = requirePositionals(positional, usageFor('kpi same-mistake'), 1);
2312
+ requireSession(sessionId, eventOptsFromFlags(flags));
2313
+ // Lineage-wide (D-119): a same-mistake RATE measured over half the work is
2314
+ // a different number, and the half it drops is the earlier one — the half
2315
+ // that holds the first occurrence every repeat is counted against.
2316
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
2317
+ const lessons = parseLessons(readFileSync(flags.lessons ?? LESSONS_MD_PATH, 'utf8'));
2318
+ const report = checkSameMistakeKpi(events, lessons, { sessionId });
2319
+ printJson(report);
2320
+ return report.ok ? 0 : 1;
2321
+ }
2322
+ // D-42/P9-26: the one command that runs against the ASSEMBLED branch rather
2323
+ // than a task worktree. Every other gate in this CLI runs inside a worktree,
2324
+ // so without this every quality claim the factory makes is a claim about a
2325
+ // worktree — and the epic gate below now refuses to ship until this has run.
2326
+ if (namespace === 'integration' && action === 'check') {
2327
+ const epicId = requireFlag(flags, 'epic');
2328
+ const projectDir = requireFlag(flags, 'project');
2329
+ const checks = readJsonFile(requireFlag(flags, 'checks'));
2330
+ const ctx = eventContextFromFlags(flags);
2331
+ const record = await runIntegrationCheck({
2332
+ epicId,
2333
+ projectDir,
2334
+ checks,
2335
+ // Unlike the per-task gate, the default here is the whole picture:
2336
+ // --run-all false opts back into short-circuiting.
2337
+ runAll: flags['run-all'] !== 'false',
2338
+ }, ctx, eventOptsFromFlags(flags));
2339
+ printJson(record);
2340
+ return record.pass ? 0 : 1;
2341
+ }
2342
+ if (namespace === 'epic' && action === 'verdict') {
2343
+ const { runEpicVerdict } = await import('./epic.js');
2344
+ const epicId = requireFlag(flags, 'epic');
2345
+ // --project is required (D-42/P9-26): the verdict cannot be rendered
2346
+ // without knowing where the integration branch actually is, and an
2347
+ // optional flag would silently mean "no check needed".
2348
+ const projectDir = requireFlag(flags, 'project');
2349
+ const ctx = eventContextFromFlags(flags);
2350
+ const outcome = await runEpicVerdict({
2351
+ epicId,
2352
+ integrationHeadSha: integrationHeadSha(projectDir, epicId),
2353
+ mcp: mcpSurfaceFor(epicId, projectDir, flags),
2354
+ goal: epicGoalFor(epicId, flags),
2355
+ // D-126: the live plan is a voter. Without this the roster is the
2356
+ // event-log fold alone, and a task an amendment added but nobody
2357
+ // dispatched is invisible rather than unfinished.
2358
+ planOpts: planOptsFromFlags(flags),
2359
+ }, ctx, eventOptsFromFlags(flags));
2360
+ printJson(outcome);
2361
+ return outcome.outcome === 'hold' ? 1 : 0;
2362
+ }
2363
+ // D-43/P9-27: `epic verdict` above stays the free read-only probe; this is
2364
+ // the verb that writes the close down. A hold closes only with
2365
+ // --override-rationale, and then the log carries the machine's verdict, the
2366
+ // blockers overridden, and the human's reason.
2367
+ if (namespace === 'epic' && action === 'close') {
2368
+ const { closeEpic } = await import('./epic.js');
2369
+ const epicId = requireFlag(flags, 'epic');
2370
+ const projectDir = requireFlag(flags, 'project');
2371
+ const ctx = eventContextFromFlags(flags);
2372
+ const record = await closeEpic({
2373
+ epicId,
2374
+ integrationHeadSha: integrationHeadSha(projectDir, epicId),
2375
+ mcp: mcpSurfaceFor(epicId, projectDir, flags),
2376
+ goal: epicGoalFor(epicId, flags),
2377
+ planOpts: planOptsFromFlags(flags),
2378
+ ...(flags['override-rationale'] !== undefined
2379
+ ? { overrideRationale: flags['override-rationale'] }
2380
+ : {}),
2381
+ }, ctx, eventOptsFromFlags(flags));
2382
+ printJson(record);
2383
+ return 0;
2384
+ }
2385
+ // P9-9/D-33: the closing spec review. The pre-code review reads a plan
2386
+ // against nothing; this one reads it against the code that now exists, which
2387
+ // is the only reading that can see the defects the code reveals. It is
2388
+ // pinned to the integration head so `epic verdict` can tell a review of this
2389
+ // branch from a review of an older one.
2390
+ if (namespace === 'epic' && action === 'spec-review') {
2391
+ const epicId = requireFlag(flags, 'epic');
2392
+ const projectDir = requireFlag(flags, 'project');
2393
+ const plan = readJsonFile(requireFlag(flags, 'plan'));
2394
+ // Evidence, not identity (interview N-2): the reviewer reports what it
2395
+ // read; the plan version and the head it was read at come from here.
2396
+ const evidence = flags.evidence ? readJsonFile(flags.evidence) : [];
2397
+ // No branch, no review. The whole point of this dispatch is that it reads
2398
+ // the code that now exists; recording one against a head that could not be
2399
+ // read would produce a review nothing can be shown to cover.
2400
+ const headSha = integrationHeadSha(projectDir, epicId);
2401
+ if (headSha === null) {
2402
+ 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 });
2403
+ }
2404
+ const record = await recordSpecReview({
2405
+ epicId,
2406
+ planVersion: plan.version,
2407
+ headSha,
2408
+ reviewedBy: requireFlag(flags, 'reviewed-by'),
2409
+ ...(flags['reviewed-by-provider']
2410
+ ? { reviewedByProvider: flags['reviewed-by-provider'] }
2411
+ : {}),
2412
+ evidence,
2413
+ }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
2414
+ printJson(record);
2415
+ // Exit 0 even when it found something: the review ran, and a spec finding
2416
+ // blocks the plan, not this command. `plan amend` is what answers it.
2417
+ return 0;
2418
+ }
2419
+ // The spec-vs-goal check. Every gate before it reads text the planner wrote;
2420
+ // this one reads the roadmap goal the operator wrote before planning began,
2421
+ // so a plan that decomposes the wrong problem stops being invisible.
2422
+ if (namespace === 'epic' && action === 'goal-check') {
2423
+ const epicId = requireFlag(flags, 'epic');
2424
+ const plan = readJsonFile(requireFlag(flags, 'plan'));
2425
+ const goal = epicGoalFor(epicId, flags);
2426
+ // Refuse rather than record a check against nothing. The blocker for an
2427
+ // undeclared goal is already in the epic gate; producing an event here
2428
+ // would be a record of a check that had no reference text.
2429
+ if (goal.goal === null || goal.milestoneId === null) {
2430
+ throw new SmithError('cli.no-epic-goal', `No roadmap milestone states a goal for "${epicId}", so there is nothing to check its plan against. Give the milestone that owns it a \`- goal:\` line in factory/specs/roadmap.md, or add the epic to an existing milestone's \`- epics:\` list.`, { epicId, milestoneId: goal.milestoneId });
2431
+ }
2432
+ const coverage = readJsonFile(requireFlag(flags, 'coverage'));
2433
+ const record = await recordGoalCheck({
2434
+ epicId,
2435
+ milestoneId: goal.milestoneId,
2436
+ goal: goal.goal,
2437
+ planVersion: plan.version,
2438
+ livePlanTaskIds: livePlanTasks(plan).map((spec) => spec.task_id),
2439
+ checkedBy: requireFlag(flags, 'checked-by'),
2440
+ ...(flags['checked-by-provider']
2441
+ ? { checkedByProvider: flags['checked-by-provider'] }
2442
+ : {}),
2443
+ coverage,
2444
+ }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
2445
+ printJson(record);
2446
+ // Exit 0 for the reason `epic spec-review` does: the check ran, and an
2447
+ // uncovered clause blocks the plan rather than this command. `plan amend`
2448
+ // is what answers it.
2449
+ return 0;
2450
+ }
2451
+ // The clause list a coverage map has to answer, printed so a judge dispatch
2452
+ // (or an operator writing one by hand) does not have to guess how
2453
+ // goalClauses() splits the goal. Read-only: no event, no finding.
2454
+ if (namespace === 'epic' && action === 'goal') {
2455
+ const epicId = requireFlag(flags, 'epic');
2456
+ printJson(epicGoalFor(epicId, flags));
2457
+ return 0;
2458
+ }
2459
+ if (namespace === 'epic' && action === 'width') {
2460
+ // Dynamic for P9-2 (test/cliBoot.test.ts): epicWidth.js reaches
2461
+ // waveConcurrency.js and through it agents-registry.js, and nothing only
2462
+ // this command needs belongs on the path every `smith --help` walks.
2463
+ const { summariseEpicWidth } = await import('./epicWidth.js');
2464
+ const eventOpts = eventOptsFromFlags(flags);
2465
+ const sessionId = typeof flags.session === 'string' ? flags.session : null;
2466
+ // The default is every session, which no other read command here does, and
2467
+ // it is the whole point: `wave audit` answers "did the waves in the log I
2468
+ // am standing in run wide", and this answers "does this factory build in
2469
+ // parallel". A close is written wherever the epic finished, so a
2470
+ // lineage-scoped default would answer the factory question with whatever
2471
+ // subset of its own history the operator happened to be inside — and
2472
+ // report a workshop of one narrow epic and forty parallel ones as narrow.
2473
+ // --session narrows back to one lineage for anyone who wants that instead.
2474
+ let events;
2475
+ if (sessionId === null) {
2476
+ const ids = listSessionIds(eventOpts.stateDir);
2477
+ events = mergeSessionLogs(await Promise.all(ids.map(async (id) => ({ sessionId: id, events: await readEvents(id, eventOpts) }))));
2478
+ }
2479
+ else {
2480
+ requireSession(sessionId, eventOpts);
2481
+ events = await readLineageEvents(sessionId, eventOpts);
2482
+ }
2483
+ const summary = summariseEpicWidth(events);
2484
+ printJson(summary);
2485
+ return summary.exitCode;
2486
+ }
2487
+ // D-41/P9-24: a finding can exist without a gate run. The wave-4 security
2488
+ // reviewer's S2 was about a file nobody at that gate could touch, and the
2489
+ // only verb that could record it was `gate run` — so recording it at all
2490
+ // meant blocking a diff that could not contain the fix. This raises the
2491
+ // finding on its own, routed to whoever claims the file.
2492
+ if (namespace === 'findings' && action === 'raise') {
2493
+ const { recordReattribution, routeFindings } = await import('./attribution.js');
2494
+ const plan = flags.plan ? readJsonFile(flags.plan) : undefined;
2495
+ // Ownership has to come from somewhere. --task names it outright; --plan
2496
+ // makes the epic the fallback owner, which resolveFindingOwner can only
2497
+ // improve on. With neither, there is no honest task_id to stamp.
2498
+ // The epic is a fallback, never a real owner: decideFindingAttribution
2499
+ // returns `gated` only when the owner equals this id, and an epic id never
2500
+ // equals a task id — so a plan-only raise always resolves or escalates.
2501
+ const defaultTaskId = flags.task ?? plan?.epic_id;
2502
+ if (defaultTaskId === undefined) {
2503
+ throw new SmithError('cli.missing-flag', 'findings raise needs --task, --plan, or both to attribute the finding.', { flag: 'task' });
2504
+ }
2505
+ const ownership = plan ? ownershipFromPlan(plan) : undefined;
2506
+ const ctx = eventContextFromFlags(flags);
2507
+ const opts = eventOptsFromFlags(flags);
2508
+ // P9-9/D-33. Scope is a property of the dispatch, not of each item: one
2509
+ // review reads one thing. --plan is required for a spec raise because the
2510
+ // version reviewed is read from the plan file rather than typed — a spec
2511
+ // finding against the wrong version points at a criterion that never moved.
2512
+ if (flags.scope !== undefined && flags.scope !== 'spec' && flags.scope !== 'diff') {
2513
+ throw new SmithError('cli.invalid-flag', `--scope must be "diff" or "spec", got "${flags.scope}".`, { flag: 'scope', value: flags.scope });
2514
+ }
2515
+ const specDispatch = flags.scope === 'spec';
2516
+ if (specDispatch && plan === undefined) {
2517
+ throw new SmithError('cli.missing-flag', 'findings raise --scope spec needs --plan: a spec finding names the plan version whose criterion it read.', { flag: 'plan' });
2518
+ }
2519
+ // Scope is decided by the dispatch, and a pre-built draft was minted by
2520
+ // some other dispatch. Accepting one here would put a diff finding into a
2521
+ // spec batch and report it as spec-scoped when it is not.
2522
+ if (specDispatch && flags.findings) {
2523
+ throw new SmithError('cli.invalid-flag', 'findings raise --scope spec takes --evidence, not --findings: scope belongs to the dispatch, and a pre-built draft was minted under a different one.', { flag: 'findings' });
2524
+ }
2525
+ // A spec finding is owned by the epic, not by whoever claims the file it
2526
+ // cites — same id recordSpecReview stamps, so the two routes into the log
2527
+ // are indistinguishable to everything downstream.
2528
+ const mintTaskId = specDispatch && plan ? `${plan.epic_id}/${RESERVED_TASK_ID}` : defaultTaskId;
2529
+ const findingsInput = [
2530
+ ...(flags.findings ? readJsonFile(flags.findings) : []),
2531
+ // The mint id, not the default one: a spec finding belongs to the epic
2532
+ // (D-33), and the dispatch's scope rides along with it so every finding
2533
+ // in the batch is spec-scoped by construction.
2534
+ ...mintFromEvidence(args, mintTaskId, specDispatch && plan ? { spec: { planVersion: plan.version } } : {}),
2535
+ ];
2536
+ // A spec dispatch never enters the routing. routeFindings would rewrite
2537
+ // task_id to whoever claims the file the finding happens to cite — and a
2538
+ // plan defect attributed to a task is exactly the deadlock D-33 exists to
2539
+ // end. gate.ts diverts for the same reason; this diverts one step earlier,
2540
+ // before attribution rather than after it.
2541
+ const routings = specDispatch
2542
+ ? findingsInput.map((input) => ({
2543
+ input,
2544
+ attribution: 'gated',
2545
+ taskId: mintTaskId,
2546
+ epicId: plan?.epic_id ?? mintTaskId,
2547
+ fromTaskId: mintTaskId,
2548
+ reason: '',
2549
+ claims: [],
2550
+ }))
2551
+ : await routeFindings(findingsInput, {
2552
+ defaultTaskId,
2553
+ // The plan states the epic outright, so nothing has to read it back
2554
+ // out of `defaultTaskId` — which on a plan-only raise IS the epic id
2555
+ // and names no epic of its own (D-49/P9-10).
2556
+ ...(plan?.epic_id ? { epicId: plan.epic_id } : {}),
2557
+ ...(ownership ? { ownership } : {}),
2558
+ }, ctx, opts);
2559
+ const raised = [];
2560
+ for (const routing of routings) {
2561
+ const result = await raiseFinding(routing.input, ctx, opts);
2562
+ // A waived fingerprint is already settled, so it mints no follow-up
2563
+ // task — same rule the gate applies, for the same reason. A spec finding
2564
+ // is skipped for the other half of that rule (gate.ts's divert): no task
2565
+ // can hold the fix, because the fix is a plan amendment.
2566
+ const isSpec = !result.suppressed && findingScope(result.finding) === SPEC_FINDING_SCOPE;
2567
+ if (!result.suppressed && !isSpec) {
2568
+ await recordReattribution(routing, result.finding, ctx, opts);
2569
+ }
2570
+ raised.push({
2571
+ findingId: result.suppressed ? null : result.finding.finding_id,
2572
+ filePath: routing.input.filePath,
2573
+ taskId: routing.taskId,
2574
+ attribution: isSpec ? SPEC_FINDING_SCOPE : routing.attribution,
2575
+ suppressed: result.suppressed,
2576
+ reason: routing.reason,
2577
+ });
2578
+ }
2579
+ printJson(raised);
2580
+ return 0;
2581
+ }
2582
+ // `--state-dir` is threaded through every findings/waivers verb below.
2583
+ // It used to be parsed and dropped here, so `findings list --state-dir X`
2584
+ // answered about the real state/events/ log instead — an answer about a
2585
+ // different session, which is worse than an error (P9-15).
2586
+ if (namespace === 'findings' && action === 'list') {
2587
+ const sessionId = requireFlag(flags, 'session');
2588
+ const eventOpts = eventOptsFromFlags(flags);
2589
+ // An empty list is an answer about the findings; it must not also be the
2590
+ // answer about the session (P9-28).
2591
+ requireSession(sessionId, eventOpts);
2592
+ const findings = await listFindings(sessionId, {
2593
+ taskId: flags.task,
2594
+ epic: flags.epic,
2595
+ status: flags.status,
2596
+ severity: flags.severity,
2597
+ category: flags.category,
2598
+ }, eventOpts);
2599
+ printJson(findings);
2600
+ return 0;
2601
+ }
2602
+ if (namespace === 'findings' && action === 'transition') {
2603
+ // Positionals first, then flags: argument order is the reading order, and
2604
+ // a usage error should name the first thing wrong on the line.
2605
+ const [findingId, newStatus] = requirePositionals(positional, usageFor('findings transition'));
2606
+ // D-136. Both amendment edges are gated on evidence this command line has
2607
+ // no way to carry: `amendsTaskIds` comes off a plan diff and
2608
+ // `amendsSatisfiedBy` off the task fold, and neither has a flag — by
2609
+ // design, since a hand-typed obligation is exactly the unchecked claim
2610
+ // D-127 closed. So every invocation naming them already fails; what it
2611
+ // failed with was a message about task ids the operator was never offered,
2612
+ // five guards into a log fold, which reads as a bug in their command line.
2613
+ // Refusing here, on the status alone, keeps argument order as the reading
2614
+ // order and lets the error name the verb that CAN take the edge.
2615
+ const AMENDMENT_ROUTES = {
2616
+ [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',
2617
+ [AMENDED_STATUS]: '`smith epic close` computes which of those tasks actually landed and discharges the finding with that evidence',
2618
+ };
2619
+ if (newStatus in AMENDMENT_ROUTES) {
2620
+ 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 });
2621
+ }
2622
+ const ctx = eventContextFromFlags(flags);
2623
+ const finding = await transitionFinding(findingId, newStatus, ctx, eventOptsFromFlags(flags));
2624
+ printJson(finding);
2625
+ return 0;
2626
+ }
2627
+ // The dispatch-time half of P9-15, shaped exactly like `lessons
2628
+ // for-dispatch`: the caller composing a task prompt asks for the block and
2629
+ // splices it. `--plan` is required rather than optional here — without it
2630
+ // the claims list is empty, every finding fails the join, and the command
2631
+ // would answer "nothing is open in your files" when it never looked.
2632
+ if (namespace === 'findings' && action === 'for-dispatch') {
2633
+ const usage = 'smith findings for-dispatch --session ... --plan plan.json --task task-id [--state-dir dir]';
2634
+ requireFlag(flags, 'plan');
2635
+ const taskId = requireFlag(flags, 'task');
2636
+ const sessionId = requireFlag(flags, 'session');
2637
+ const eventOpts = eventOptsFromFlags(flags);
2638
+ requireSession(sessionId, eventOpts);
2639
+ if (positional.length > 0) {
2640
+ throw new SmithError('cli.usage', `Unexpected argument. Usage: ${usage}`, { positional });
2641
+ }
2642
+ printJson(await findingsForDispatch({ sessionId, taskId, claims: claimsForDispatch(flags) }, eventOpts));
2643
+ return 0;
2644
+ }
2645
+ // Re-dating a finding's evidence (P9-15). Deliberately its own verb and not
2646
+ // a `transition`: re-verification does not change finding_status, and the
2647
+ // two statuses it could be confused with mean something else entirely
2648
+ // (`confirmed` cannot be re-entered, `refuted` says the finding was wrong).
2649
+ if (namespace === 'findings' && action === 'reverify') {
2650
+ const [findingId] = requirePositionals(positional, usageFor('findings reverify'));
2651
+ const ctx = eventContextFromFlags(flags);
2652
+ const eventOpts = eventOptsFromFlags(flags);
2653
+ requireSession(ctx.sessionId, eventOpts);
2654
+ await reverifyFinding(findingId, flags.note ?? '', ctx, eventOpts);
2655
+ printJson({ findingId, note: flags.note ?? '' });
2656
+ return 0;
2657
+ }
2658
+ // D-21 Part 4. Corrects a malformed amends_task_ids entry on a finding
2659
+ // parked at amend-pending -- the shape a malformed "plan amend" --changes
2660
+ // file can write (parts 1-3 of D-21 now refuse that at the source, but the
2661
+ // log is append-only). Never a hand-typed obligation: repairObligation's own
2662
+ // guards refuse anything this verb is not meant to do.
2663
+ if (namespace === 'findings' && action === 'repair-obligation') {
2664
+ const [findingId] = requirePositionals(positional, usageFor('findings repair-obligation'));
2665
+ // Comma-split, not repeated: a task id has no commas, and a repaired
2666
+ // obligation routinely names more than one (D-136's --findings precedent).
2667
+ //
2668
+ // Unlike --findings/--sites, a stray empty SEGMENT is not filtered out
2669
+ // here (D-21 Part 4 review, S4 behavioral-drift): "a,,b" used to filter
2670
+ // down to ['a', 'b'], so repairObligation's guard 6 -- every replacement
2671
+ // id must be a non-empty string -- was implemented and unit-tested but
2672
+ // unreachable through this command line; an operator's typo'd double
2673
+ // comma was silently corrected instead of refused. Only the flag's own
2674
+ // value being entirely blank (nothing typed at all, the one shape a
2675
+ // string flag can use to mean "zero entries") maps to an empty list, so
2676
+ // guard 3 (cannot empty) stays reachable too.
2677
+ const replaceWithRaw = requireFlag(flags, 'replace-with');
2678
+ const replaceWith = replaceWithRaw.trim() === '' ? [] : replaceWithRaw.split(',').map((id) => id.trim());
2679
+ const ctx = eventContextFromFlags(flags);
2680
+ const eventOpts = eventOptsFromFlags(flags);
2681
+ requireSession(ctx.sessionId, eventOpts);
2682
+ const finding = await repairObligation({ findingId, replaceWith, reason: requireFlag(flags, 'reason') }, ctx, eventOpts);
2683
+ printJson(finding);
2684
+ return 0;
2685
+ }
2686
+ if (namespace === 'waivers' && action === 'pending') {
2687
+ const [epic] = requirePositionals(positional, usageFor('waivers pending'));
2688
+ const sessionId = requireFlag(flags, 'session');
2689
+ const eventOpts = eventOptsFromFlags(flags);
2690
+ requireSession(sessionId, eventOpts);
2691
+ const pending = await pendingBatch(epic, { sessionId }, eventOpts);
2692
+ printJson(pending);
2693
+ return 0;
2694
+ }
2695
+ if (namespace === 'waivers' && action === 'apply') {
2696
+ const [decisionsFile] = requirePositionals(positional, usageFor('waivers apply'));
2697
+ const decisions = readJsonFile(decisionsFile);
2698
+ const ctx = eventContextFromFlags(flags);
2699
+ const results = await applyBatch(decisions, ctx, eventOptsFromFlags(flags));
2700
+ printJson(results);
2701
+ return 0;
2702
+ }
2703
+ if (namespace === 'lessons' && action === 'candidates') {
2704
+ const { openDb } = await import('./db/projector.js');
2705
+ const { lessonsPage } = await import('./db/queries.js');
2706
+ const dbPath = flags.db ?? STATE_DB_PATH;
2707
+ const handle = openDb(dbPath);
2708
+ try {
2709
+ const scope = flags.session ? { sessionId: flags.session } : {};
2710
+ printJson(lessonsPage(handle.db, scope).pending);
2711
+ return 0;
2712
+ }
2713
+ finally {
2714
+ handle.sqlite.close();
2715
+ }
2716
+ }
2717
+ // The hand-authored entrance to the pipeline (P9-34). `smith dream` only
2718
+ // ever sees four checkpoint shapes in a log, so a rule derived by reading a
2719
+ // whole run had no way in except `smith event append`, which applies no
2720
+ // novelty check at all. Exit 1 on a novelty rejection: the candidate is
2721
+ // logged either way, but the operator must look before assuming it landed.
2722
+ if (namespace === 'lessons' && action === 'raise') {
2723
+ const { raiseLessonCandidate } = await import('./lessons.js');
2724
+ const eventOpts = eventOptsFromFlags(flags);
2725
+ const ctx = eventContextFromFlags(flags);
2726
+ requireSession(ctx.sessionId, eventOpts);
2727
+ const result = await raiseLessonCandidate({
2728
+ statement: requireFlag(flags, 'statement'),
2729
+ lessonType: requireFlag(flags, 'lesson-type'),
2730
+ lessonScope: requireFlag(flags, 'lesson-scope'),
2731
+ provenanceEventIds: requireFlag(flags, 'provenance')
2732
+ .split(',')
2733
+ .map((id) => id.trim())
2734
+ .filter(Boolean),
2735
+ provenanceSessionId: flags['provenance-session'],
2736
+ evidence: flags.evidence,
2737
+ findingCategory: flags['finding-category'],
2738
+ claimPath: flags['claim-path'],
2739
+ agentRole: flags['agent-role'],
2740
+ caseType: flags['case-type'],
2741
+ lessonId: flags['lesson-id'],
2742
+ }, ctx, eventOpts, await noveltyOptsFromFlags(flags));
2743
+ printJson(result);
2744
+ return result.novel ? 0 : 1;
2745
+ }
2746
+ // `approve` and `reject` are the same verb with the destination fixed
2747
+ // (P9-1): reject maps to `invalidated`, the status ui/server's reject route
2748
+ // already writes. Both refuse a transition LEGAL_LESSON_TRANSITIONS does
2749
+ // not allow rather than letting a hand-typed envelope poison the memory.
2750
+ //
2751
+ // P9-34/P9-35: an approval also carries the novelty review — `--statement`
2752
+ // is scored by the same gate `raise` uses, and even an unedited approval
2753
+ // reports what the text most resembles, because the gate only catches a
2754
+ // near-verbatim re-statement and the operator is the rest of the check.
2755
+ // Exit 1 when the text that just landed in memory is not novel: it landed
2756
+ // either way, but that is a "look at this", not a clean run.
2757
+ if (namespace === 'lessons' && (action === 'approve' || action === 'reject')) {
2758
+ const { transitionLesson } = await import('./lessons.js');
2759
+ const [lessonId] = requirePositionals(positional, usageFor(`lessons ${action}`));
2760
+ const eventOpts = eventOptsFromFlags(flags);
2761
+ const ctx = eventContextFromFlags(flags);
2762
+ requireSession(ctx.sessionId, eventOpts);
2763
+ const row = await transitionLesson(lessonId, action === 'approve' ? 'approved' : 'invalidated', ctx, eventOpts, {
2764
+ note: flags.note,
2765
+ edit: {
2766
+ statement: flags.statement,
2767
+ lessonType: flags['lesson-type'],
2768
+ lessonScope: flags['lesson-scope'],
2769
+ agentRole: flags['agent-role'],
2770
+ caseType: flags['case-type'],
2771
+ },
2772
+ acceptDuplicate: flags['accept-duplicate'] === 'true',
2773
+ ...(await noveltyOptsFromFlags(flags)),
2774
+ });
2775
+ printJson(row);
2776
+ return row.novelty && !row.novelty.novel ? 1 : 0;
2777
+ }
2778
+ if (namespace === 'lessons' && action === 'compile') {
2779
+ const { openDb } = await import('./db/projector.js');
2780
+ const { lessonsPage } = await import('./db/queries.js');
2781
+ const { compileLessons } = await import('./lessons.js');
2782
+ const dbPath = flags.db ?? STATE_DB_PATH;
2783
+ const outPath = flags.out ?? LESSONS_MD_PATH;
2784
+ const handle = openDb(dbPath);
2785
+ try {
2786
+ const scope = flags.session ? { sessionId: flags.session } : {};
2787
+ const approved = lessonsPage(handle.db, scope).approved;
2788
+ const markdown = compileLessons(approved.map((l) => ({
2789
+ lessonId: l.lessonId,
2790
+ lessonScope: l.lessonScope,
2791
+ statement: l.statement,
2792
+ findingCategory: l.findingCategory,
2793
+ claimPath: l.claimPath,
2794
+ agentRole: l.agentRole,
2795
+ caseType: l.caseType,
2796
+ })));
2797
+ writeFileSync(outPath, markdown, 'utf8');
2798
+ printJson({ outPath, lessonsCompiled: approved.length });
2799
+ return 0;
2800
+ }
2801
+ finally {
2802
+ handle.sqlite.close();
2803
+ }
2804
+ }
2805
+ // The dispatch-time half of the lessons loop (P9-2): the caller composing a
2806
+ // role prompt asks for the block and splices it. Claims come from the
2807
+ // immutable plan rather than a repeated `--claim` flag — parseArgs keeps one
2808
+ // value per flag, and a claim glob may itself contain a comma (`src/{a,b}/**`),
2809
+ // so neither repetition nor splitting can carry a claims list faithfully.
2810
+ if (namespace === 'lessons' && action === 'for-dispatch') {
2811
+ const { lessonsForDispatch } = await import('./lessons.js');
2812
+ const [role] = requirePositionals(positional, usageFor('lessons for-dispatch'));
2813
+ printJson(lessonsForDispatch(role, claimsForDispatch(flags), {
2814
+ ...(flags['agents-dir'] ? { agentsDir: flags['agents-dir'] } : {}),
2815
+ ...(flags.lessons ? { lessonsPath: flags.lessons } : {}),
2816
+ caseType: caseForDispatch(flags),
2817
+ }));
2818
+ return 0;
2819
+ }
2820
+ // The other half of the lessons loop, and the one it never had: `lessons.md`
2821
+ // only ever grows, and nothing read an entry back to ask whether it still
2822
+ // does anything. Read-only over both the log and the corpus — it recommends
2823
+ // `retire`, it does not retire, because a lesson is a standing instruction a
2824
+ // human wrote and §9.6 reserves supersession for a human's call. Exit 1 on
2825
+ // anything but `clean`, for the same reason `kpi same-mistake` does: a corpus
2826
+ // that cannot be read is not a corpus that is fine.
2827
+ if (namespace === 'lessons' && action === 'audit') {
2828
+ const { auditLessons } = await import('./lessonAudit.js');
2829
+ const [sessionId] = requirePositionals(positional, usageFor('lessons audit'), 1);
2830
+ requireSession(sessionId, eventOptsFromFlags(flags));
2831
+ // Lineage-wide (D-119), and here the reason is sharper than elsewhere: the
2832
+ // question is whether an entry has EVER fired, and a session-scoped read
2833
+ // answers "not in this half of the epic" while printing `retire`.
2834
+ const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
2835
+ const lessons = parseLessons(readFileSync(flags.lessons ?? LESSONS_MD_PATH, 'utf8'));
2836
+ const report = auditLessons(events, lessons, { sessionId });
2837
+ printJson(report);
2838
+ return report.ok ? 0 : 1;
2839
+ }
2840
+ if (namespace === 'dream') {
2841
+ const { dream } = await import('./lessons.js');
2842
+ const sessionId = requireFlag(flags, 'session');
2843
+ // Before the lineage read, so a typo costs a message and not a full log walk.
2844
+ const since = isoDateFlag(flags, 'since')?.toISOString();
2845
+ const eventOpts = eventOptsFromFlags(flags);
2846
+ requireSession(sessionId, eventOpts);
2847
+ // Lineage-wide (D-119). Dreaming is the pass that distils what went wrong
2848
+ // into lessons, and what went wrong belongs to the epic: an error logged
2849
+ // before a session split, and the retry that fixed it after, are one story
2850
+ // that a session-scoped read tells in halves. `--since` still bounds it.
2851
+ const events = await readLineageEvents(sessionId, eventOpts);
2852
+ const ctx = eventContextFromFlags(flags);
2853
+ const result = await dream(events, {
2854
+ sessionId: ctx.sessionId,
2855
+ planVersion: ctx.planVersion,
2856
+ causalParent: ctx.causalParent,
2857
+ actor: ctx.actor,
2858
+ }, eventOpts, { since, ...(await noveltyOptsFromFlags(flags)) });
2859
+ printJson(result);
2860
+ return 0;
2861
+ }
2862
+ // D-31, D-20 / P9-11. The two halves of a judge turn and the gap between
2863
+ // them. Dispatch declares the file; report proves it exists and parses;
2864
+ // outstanding is the difference, and exits non-zero while it is non-empty so
2865
+ // a re-poke loop can branch on the status instead of parsing stdout.
2866
+ if (namespace === 'judge' && action === 'dispatch') {
2867
+ const stored = await recordJudgeDispatch({
2868
+ taskId: requireFlag(flags, 'task'),
2869
+ role: requireFlag(flags, 'role'),
2870
+ round: boundedIntFlag(flags, 'round', { min: 1 }) ?? 1,
2871
+ artifactPath: requireFlag(flags, 'artifact'),
2872
+ // Required, unlike --provider/--model-tier below: P9-23's dispatch
2873
+ // asymmetry audit compares model ids, and this verb records one half of
2874
+ // the reviewer/verifier pair it checks.
2875
+ model: requireFlag(flags, 'model'),
2876
+ ...(flags.provider ? { provider: flags.provider } : {}),
2877
+ ...(flags['model-tier'] ? { modelTier: flags['model-tier'] } : {}),
2878
+ }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
2879
+ printJson(stored);
2880
+ return 0;
2881
+ }
2882
+ if (namespace === 'judge' && action === 'report') {
2883
+ // Optional here, unlike on dispatch, so an absent --round must stay absent
2884
+ // rather than defaulting: recordJudgeReport reads the dispatched round when
2885
+ // the caller names none. A present one is still a count (D-210's class) --
2886
+ // `--round abc` used to reach the reporter as NaN and come back as "is on
2887
+ // round 1, not round NaN", which blames the round for what the flag did.
2888
+ const reportRound = boundedIntFlag(flags, 'round', { min: 1 });
2889
+ const report = await recordJudgeReport({
2890
+ taskId: requireFlag(flags, 'task'),
2891
+ role: requireFlag(flags, 'role'),
2892
+ ...(reportRound === undefined ? {} : { round: reportRound }),
2893
+ ...(flags.artifact ? { artifactPath: flags.artifact } : {}),
2894
+ // Here the role is already named by --role, so the valueless spelling
2895
+ // is the right one and 'true' means what it says.
2896
+ ...(flags['no-findings'] === 'true' ? { noFindings: true } : {}),
2897
+ }, eventContextFromFlags(flags), eventOptsFromFlags(flags));
2898
+ printJson(report);
2899
+ return 0;
2900
+ }
2901
+ if (namespace === 'judge' && action === 'outstanding') {
2902
+ const sessionId = requireFlag(flags, 'session');
2903
+ const eventOpts = eventOptsFromFlags(flags);
2904
+ requireSession(sessionId, eventOpts);
2905
+ const open = outstandingJudges(await readJudgeTurns(requireFlag(flags, 'task'), { sessionId }, eventOpts));
2906
+ printJson(open);
2907
+ return open.length > 0 ? 1 : 0;
2908
+ }
2909
+ if (namespace === 'judge' && action === 'escalations') {
2910
+ // Kept out of the boot graph by habit rather than by need -- the module
2911
+ // imports nothing at runtime -- so that a later dependency added to it
2912
+ // cannot quietly cost `smith --help` a database (P9-2, test/cliBoot.test.ts).
2913
+ const { openQuorumEscalations, summariseEscalations } = await import('./quorumEscalations.js');
2914
+ const sessionId = requireFlag(flags, 'session');
2915
+ const eventOpts = eventOptsFromFlags(flags);
2916
+ requireSession(sessionId, eventOpts);
2917
+ // The lineage and not the session: an escalation raised in one operator
2918
+ // session is owed until answered, and the answer routinely lands in the
2919
+ // next one (judges.ts:248 -- an epic outgrowing one session is the
2920
+ // recommended shape). Folding one session would report a settled case as
2921
+ // open, and an open one as nothing at all.
2922
+ const summary = summariseEscalations(openQuorumEscalations(await readLineageEvents(sessionId, eventOpts)));
2923
+ printJson(summary);
2924
+ return summary.exitCode;
2925
+ }
2926
+ if (namespace === 'judge' && action === 'preflight') {
2927
+ // The one provider question that can be answered without spending a call.
2928
+ // Deliberately ahead of `judge run` in this file for the same reason it is
2929
+ // ahead of it in the runbook: an operator reaching for a calibration call
2930
+ // to find out why a provider keeps failing usually needed this instead.
2931
+ const report = judgePreflight(flags.policy);
2932
+ printJson(report);
2933
+ return report.problems.length > 0 ? 1 : 0;
2934
+ }
2935
+ if (namespace === 'judge' && action === 'run') {
2936
+ // Manual invocation for calibration (docs/runbooks/providers.md) — never
2937
+ // touches the event log or quorum.ts; `--shadow` is an output-only note
2938
+ // for the operator's own bookkeeping (crosscheck.yml's provider `mode`
2939
+ // is the only thing that ever grants real gating power).
2940
+ const providerName = requireFlag(flags, 'provider');
2941
+ const request = readJsonFile(requireFlag(flags, 'request'));
2942
+ const result = await runJudge(providerName, request);
2943
+ printJson({ ...result, shadow: flags.shadow === 'true' });
2944
+ return 0;
2945
+ }
2946
+ if (namespace === 'crossfind') {
2947
+ // crosscheck.yml's `independent_finder`, driven by hand. Three verbs, and
2948
+ // the split between them is the operator mandate made operable: `request`
2949
+ // shows exactly what would leave the machine WITHOUT sending it, `run`
2950
+ // sends it, and `reconcile` needs no provider at all.
2951
+ const policy = loadCrosscheckPolicy(flags.policy);
2952
+ if (action === 'request') {
2953
+ // Read-only and network-free by construction: it builds the JudgeRequest
2954
+ // and prints it. Every refusal independentFinderRequest() can raise —
2955
+ // send_diff false, empty diff, oversized diff — fires here too, so an
2956
+ // operator can find out what the policy forbids before spending a call.
2957
+ const request = independentFinderRequest({
2958
+ taskId: requireFlag(flags, 'task'),
2959
+ diff: readFileSync(requireFlag(flags, 'diff'), 'utf8'),
2960
+ diffRef: requireFlag(flags, 'diff-ref'),
2961
+ ...(repeated.criterion ? { criteria: repeated.criterion } : {}),
2962
+ budget: judgeBudgetFromFlags(flags),
2963
+ policy: policy.independentFinder,
2964
+ });
2965
+ printJson(request);
2966
+ return 0;
2967
+ }
2968
+ if (action === 'reconcile') {
2969
+ // Offline calibration: two saved lists in, one report out, no provider
2970
+ // invoked and nothing appended to the log. This is the verb to run over
2971
+ // a shadow-mode backlog before deciding whether to flip `mode: active`.
2972
+ const report = reconcile({
2973
+ taskId: requireFlag(flags, 'task'),
2974
+ native: readJsonFile(requireFlag(flags, 'native')),
2975
+ independent: readJsonFile(requireFlag(flags, 'independent')),
2976
+ policy: policy.independentFinder,
2977
+ });
2978
+ printJson(report);
2979
+ return report.gates ? 1 : 0;
2980
+ }
2981
+ if (action === 'run') {
2982
+ const sessionId = requireFlag(flags, 'session');
2983
+ const taskId = requireFlag(flags, 'task');
2984
+ const eventOpts = eventOptsFromFlags(flags);
2985
+ requireSession(sessionId, eventOpts);
2986
+ // Lineage-wide (D-119), like every other read of a task's findings: a
2987
+ // finding raised before a session split is still a finding this diff has
2988
+ // to be reconciled against.
2989
+ const native = (await listFindings(sessionId, { taskId }, eventOpts)).filter((f) => flags.status === undefined
2990
+ ? OPEN_FINDING_STATUSES.has(f.finding_status)
2991
+ : f.finding_status === flags.status);
2992
+ // Built separately from the run for the reason RunIndependentFinderInput
2993
+ // says: `crossfind request` and `crossfind run` must assemble the same
2994
+ // bytes, so the thing the operator inspected is the thing that is sent.
2995
+ const request = independentFinderRequest({
2996
+ taskId,
2997
+ diff: readFileSync(requireFlag(flags, 'diff'), 'utf8'),
2998
+ diffRef: requireFlag(flags, 'diff-ref'),
2999
+ ...(repeated.criterion ? { criteria: repeated.criterion } : {}),
3000
+ budget: judgeBudgetFromFlags(flags),
3001
+ policy: policy.independentFinder,
3002
+ });
3003
+ const result = await runIndependentFinder({ taskId, request, native, policy }, eventContextFromFlags(flags), eventOpts);
3004
+ // `raise` is printed, never minted (crossFinding.ts's contract): which
3005
+ // findings enter a gate is the operator's call, and `smith findings
3006
+ // raise` is where they make it.
3007
+ printJson({
3008
+ report: result.report,
3009
+ runs: result.runs,
3010
+ raise: result.raise,
3011
+ reconciled_event_id: result.reconciledEventId,
3012
+ native_considered: native.length,
3013
+ });
3014
+ return result.report.gates ? 1 : 0;
3015
+ }
3016
+ }
3017
+ if (namespace === 'ui' && action === 'serve') {
3018
+ // Before the dynamic import below, so a mistyped port costs a message and
3019
+ // not a build: unbuilt, ui.not-built preempted the parse and the operator
3020
+ // was told to run pnpm build:server for a typo that would still be there
3021
+ // afterwards. Built, listen() threw ERR_SOCKET_BAD_PORT -- exit 1, but a
3022
+ // Node stack trace on stdout with no error.code, the one shape every other
3023
+ // error from this CLI has.
3024
+ const port = boundedIntFlag(flags, 'port', { min: 1, max: 65535 }) ?? 4680;
3025
+ // ui/server is a separate TS project (ui/server/tsconfig.json) built to
3026
+ // ui/server/dist/index.js — dynamically imported here (via a computed,
3027
+ // non-literal specifier, so tsc never tries to fold it into THIS
3028
+ // project's rootDir) rather than statically imported, so `smith`'s own
3029
+ // build/bin contract (factory/orchestrator/dist/cli.js) stays untouched
3030
+ // whether or not the UI has been built. See ui/server/src/app.ts's
3031
+ // header comment for why ui/server depends on the BUILT orchestrator
3032
+ // dist/, not this file's src/ tree.
3033
+ const serverEntry = path.join(REPO_ROOT, 'ui', 'server', 'dist', 'index.js');
3034
+ let mod;
3035
+ try {
3036
+ mod = await import(pathToFileURL(serverEntry).href);
3037
+ }
3038
+ catch (err) {
3039
+ throw new SmithError('ui.not-built', `ui/server is not built (expected ${serverEntry}). Run "pnpm build:server" first.`, { cause: err instanceof Error ? err.message : String(err) });
3040
+ }
3041
+ const dbPath = flags.db ?? STATE_DB_PATH;
3042
+ const stateDir = flags['state-dir'];
3043
+ // --roadmap-path travels with --db/--state-dir, and dropping it was not a
3044
+ // missing feature but a silent data swap: the read path re-projects each
3045
+ // session on the first request (app.ts's createRefresher), and apply()
3046
+ // rebuilds the whole milestones table from a roadmap file while it is
3047
+ // there. Unset, that file falls back to black-smith's own
3048
+ // factory/specs/roadmap.md — so serving another project's db showed this
3049
+ // repo's roadmap from the first page load onward.
3050
+ const roadmapPath = flags['roadmap-path'];
3051
+ // Same argument for --specs-dir: createRefresher re-projects on every
3052
+ // request, and the plan files it consults to place an unstamped epic
3053
+ // (D-246) must be the served db's, not this repo's.
3054
+ const specsDir = flags['specs-dir'];
3055
+ mod.serve({
3056
+ port,
3057
+ dbPath,
3058
+ ...(stateDir ? { stateDir } : {}),
3059
+ ...(roadmapPath ? { roadmapPath } : {}),
3060
+ ...(specsDir ? { specsDir } : {}),
3061
+ });
3062
+ return 0;
3063
+ }
3064
+ if (namespace === 'db' && action === 'rebuild') {
3065
+ const { rebuild: rebuildDb } = await import('./db/projector.js');
3066
+ const dbPath = flags.db ?? STATE_DB_PATH;
3067
+ const sessions = flags.session ? [flags.session] : 'all';
3068
+ // A named session must exist; `all` legitimately finds nothing (P9-28).
3069
+ if (flags.session)
3070
+ requireSession(flags.session, eventOptsFromFlags(flags));
3071
+ const result = await rebuildDb(dbPath, sessions, dbOptsFromFlags(flags));
3072
+ printJson(result);
3073
+ return 0;
3074
+ }
3075
+ if (namespace === 'db' && action === 'apply') {
3076
+ const { apply: applyDb } = await import('./db/projector.js');
3077
+ const dbPath = flags.db ?? STATE_DB_PATH;
3078
+ const sessionId = requireFlag(flags, 'session');
3079
+ // Applying a session that has no log would report 0 events applied and
3080
+ // exit 0 — a successful-looking no-op is the worst possible answer for a
3081
+ // command whose whole job is "make the DB match the log" (P9-28).
3082
+ requireSession(sessionId, eventOptsFromFlags(flags));
3083
+ const result = await applyDb(dbPath, sessionId, dbOptsFromFlags(flags));
3084
+ printJson(result);
3085
+ return 0;
3086
+ }
3087
+ if (namespace === 'stats') {
3088
+ const { openDb } = await import('./db/projector.js');
3089
+ const { analytics, errorsPage, kanban, lessonsPage, overview, providerAgreement, projectedLineage, roadmapPage, taskDetail, timeline, } = await import('./db/queries.js');
3090
+ const dbPath = flags.db ?? STATE_DB_PATH;
3091
+ const handle = openDb(dbPath);
3092
+ try {
3093
+ const scope = flags.session
3094
+ ? { sessionId: flags.session }
3095
+ : {};
3096
+ if (flags.lineage) {
3097
+ // --lineage widens the scope from the session asked about to the whole
3098
+ // chain it continues, resolved off the projection so `stats` still needs
3099
+ // nothing but a db. It has nothing to widen without --session, and
3100
+ // saying so beats silently reporting every session as "the lineage".
3101
+ scope.sessionIds = projectedLineage(handle.db, requireFlag(flags, 'session'));
3102
+ }
3103
+ if (action === 'overview') {
3104
+ printJson(overview(handle.db, scope));
3105
+ return 0;
3106
+ }
3107
+ if (action === 'timeline') {
3108
+ printJson(timeline(handle.db, {
3109
+ ...scope,
3110
+ taskId: flags.task,
3111
+ epicId: flags.epic,
3112
+ causalChainFor: flags['causal-chain-for'],
3113
+ }));
3114
+ return 0;
3115
+ }
3116
+ if (action === 'kanban') {
3117
+ const epic = requireFlag(flags, 'epic');
3118
+ printJson(kanban(handle.db, epic, scope));
3119
+ return 0;
3120
+ }
3121
+ if (action === 'task') {
3122
+ const taskId = requireFlag(flags, 'task');
3123
+ const detail = taskDetail(handle.db, taskId);
3124
+ printJson(detail);
3125
+ return detail ? 0 : 1;
3126
+ }
3127
+ if (action === 'lessons') {
3128
+ printJson(lessonsPage(handle.db, scope));
3129
+ return 0;
3130
+ }
3131
+ if (action === 'errors') {
3132
+ printJson(errorsPage(handle.db, scope));
3133
+ return 0;
3134
+ }
3135
+ if (action === 'analytics') {
3136
+ printJson(analytics(handle.db, scope));
3137
+ return 0;
3138
+ }
3139
+ if (action === 'roadmap') {
3140
+ printJson(roadmapPage(handle.db, scope));
3141
+ return 0;
3142
+ }
3143
+ if (action === 'providers') {
3144
+ const since = isoDateFlag(flags, 'since')?.toISOString();
3145
+ printJson(providerAgreement(handle.db, scope, { since }));
3146
+ return 0;
3147
+ }
3148
+ }
3149
+ finally {
3150
+ handle.sqlite.close();
3151
+ }
3152
+ }
3153
+ // Unreachable while usage.ts and the branches above agree, because
3154
+ // isDocumented rejected everything they do not both name — but a function
3155
+ // returning `Promise<number>` has to return on every path, and an
3156
+ // unreachable `throw` would be a worse answer than the honest one. What
3157
+ // keeps the two in agreement is test/usage.test.ts, not this line.
3158
+ printJson({
3159
+ error: {
3160
+ message: `Unknown command: ${[namespace, action].filter(Boolean).join(' ')}`,
3161
+ },
3162
+ });
3163
+ return 1;
3164
+ }
3165
+ main()
3166
+ .then((code) => {
3167
+ process.exitCode = code;
3168
+ })
3169
+ .catch((err) => {
3170
+ // A SmithError is a designed answer: its code names the failure and its
3171
+ // details name the record, so a stack would only add noise. Anything else
3172
+ // is a bug, and D-135 is what that costs — `{"message":"Cannot read
3173
+ // properties of undefined (reading 'indexOf')"}` was the entire output of
3174
+ // a failed `smith findings list`, with no file, no line and no clue which
3175
+ // of the three helpers that call `.indexOf` had thrown. The stack is the
3176
+ // only part of an unexpected error worth having.
3177
+ const details = err instanceof SmithError
3178
+ ? { code: err.code, message: err.message, details: err.details }
3179
+ : {
3180
+ message: err instanceof Error ? err.message : String(err),
3181
+ ...(err instanceof Error && err.stack ? { stack: err.stack.split('\n') } : {}),
3182
+ };
3183
+ printJson({ error: details });
3184
+ process.exitCode = 1;
3185
+ });