@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,141 @@
1
+ import { SmithError } from './errors.js';
2
+ import { appendEvent } from './events.js';
3
+ import { runGit as git } from './git.js';
4
+ import { run } from './testgate.js';
5
+ import { integrationBranchName, RESERVED_TASK_ID } from './worktree.js';
6
+ /**
7
+ * D-42/P9-26, second half. Moving the worktrees outside the project root
8
+ * (worktree.ts) fixes the tool that went red; it does not fix the reason
9
+ * nobody noticed for six tasks. Every check this factory runs — schema,
10
+ * tests, lint, review — runs inside a task worktree, so every quality claim
11
+ * it makes is a claim about a worktree. The assembled integration branch had
12
+ * never had one command run against it when the epic was declared shippable.
13
+ * The run that caught D-42 took eleven seconds and happened because a human
14
+ * typed it.
15
+ *
16
+ * This module is that run, as a logged fact: the full check suite, executed
17
+ * at the project root while the integration branch is checked out, recorded
18
+ * as an `integration-check` event that epic.ts's verdict gate then requires.
19
+ *
20
+ * It REFUSES rather than acts in the two cases where acting would be worse
21
+ * than stopping — a project that isn't on the integration branch (checking
22
+ * it out would be a destructive git op on the operator's own clone) and a
23
+ * dirty working tree (the checks would certify something that is not the
24
+ * branch). Both are one operator command away from fixed, and both are
25
+ * silent corruption of the record if guessed at.
26
+ */
27
+ export class IntegrationCheckError extends SmithError {
28
+ }
29
+ export const INTEGRATION_CHECK_EVENT = 'integration-check';
30
+ function branchHead(projectDir, branch) {
31
+ try {
32
+ return git(projectDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]) || null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * The current head of `smith/<epic>/integration`, or null when the epic has
40
+ * no integration branch (never planned, never dispatched, wrong --epic).
41
+ * Read here rather than inside epic.ts so the verdict stays git-free and
42
+ * purely a fold over events — the CLI passes the sha in.
43
+ */
44
+ export function integrationHeadSha(projectDir, epicId) {
45
+ return branchHead(projectDir, integrationBranchName(epicId));
46
+ }
47
+ /** Last-wins fold: the most recent recorded check for this epic, or null. */
48
+ export function latestIntegrationCheck(events, epicId) {
49
+ let latest = null;
50
+ for (const event of events) {
51
+ if (event.record.event_type !== INTEGRATION_CHECK_EVENT)
52
+ continue;
53
+ const payload = event.record.payload;
54
+ if (payload.epic_id !== epicId)
55
+ continue;
56
+ latest = {
57
+ epicId,
58
+ branch: String(payload.branch ?? ''),
59
+ headSha: String(payload.head_sha ?? ''),
60
+ pass: payload.pass === true,
61
+ results: (payload.results ?? []),
62
+ eventId: event.event_id,
63
+ ts: event.record.ts,
64
+ };
65
+ }
66
+ return latest;
67
+ }
68
+ /**
69
+ * Run the check suite at the project root against the epic's assembled
70
+ * integration branch and record the outcome as an event.
71
+ *
72
+ * A FAILING suite is a recorded failure, not a throw — that is the whole
73
+ * point of the record. A suite that could not honestly be run at all (no
74
+ * checks, wrong branch, dirty tree, no branch) throws instead, because the
75
+ * one thing this must never do is write down a pass that nothing earned.
76
+ */
77
+ export async function runIntegrationCheck(input, ctx, opts = {}) {
78
+ const { epicId, projectDir } = input;
79
+ const branch = integrationBranchName(epicId);
80
+ // testgate.run([]) returns pass: true for an empty list — correct there
81
+ // (a task claiming no checks has nothing to short-circuit), and a silent
82
+ // forgery here. "No checks configured" must never read back as "the
83
+ // assembled branch passed its checks".
84
+ if (input.checks.length === 0) {
85
+ throw new IntegrationCheckError('integration.no-checks', `Refusing to certify epic "${epicId}" with an empty check list: an empty suite would record a pass nothing ran for.`, { epicId, branch });
86
+ }
87
+ const headSha = branchHead(projectDir, branch);
88
+ if (headSha === null) {
89
+ throw new IntegrationCheckError('integration.branch-missing', `Epic "${epicId}" has no integration branch (${branch}) in ${projectDir} — there is nothing assembled to check.`, { epicId, branch, projectDir });
90
+ }
91
+ let currentBranch;
92
+ try {
93
+ currentBranch = git(projectDir, ['branch', '--show-current']);
94
+ }
95
+ catch (cause) {
96
+ throw new IntegrationCheckError('integration.not-a-repo', `Could not read the current branch in ${projectDir}: ${String(cause)}`, { projectDir });
97
+ }
98
+ // The checks run against the working tree, so the working tree has to BE
99
+ // the integration branch. Checking it out for the operator would be a
100
+ // destructive git op on their own clone (guardrails.md) — refuse instead.
101
+ if (currentBranch !== branch) {
102
+ throw new IntegrationCheckError('integration.wrong-branch', `${projectDir} is on "${currentBranch || '(detached HEAD)'}", not the integration branch "${branch}". Check it out first — this command will not move your working tree for you.`, { epicId, branch, currentBranch, projectDir });
103
+ }
104
+ // `--untracked-files=all` explicitly: the mode is configurable, and a
105
+ // `status.showUntrackedFiles = no` anywhere in git's config chain made an
106
+ // untracked-only tree read back as clean — which is exactly the tree someone
107
+ // forgot to commit, certified under a head it does not match (D-178).
108
+ const dirty = git(projectDir, ['status', '--porcelain', '--untracked-files=all']);
109
+ if (dirty) {
110
+ throw new IntegrationCheckError('integration.dirty-tree', `${projectDir} has uncommitted changes; a check run against a dirty tree certifies something that is not ${branch} at ${headSha.slice(0, 8)}.`, { epicId, branch, projectDir, dirtyEntries: dirty.split('\n').length });
111
+ }
112
+ const outcome = await run(input.checks, {
113
+ cwd: projectDir,
114
+ runAll: input.runAll ?? true,
115
+ ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
116
+ });
117
+ const stored = await appendEvent({
118
+ session_id: ctx.sessionId,
119
+ actor: ctx.actor ?? 'system',
120
+ event_type: INTEGRATION_CHECK_EVENT,
121
+ task_id: `${epicId}/${RESERVED_TASK_ID}`,
122
+ plan_version: ctx.planVersion,
123
+ causal_parent: ctx.causalParent,
124
+ payload: {
125
+ epic_id: epicId,
126
+ branch,
127
+ head_sha: headSha,
128
+ pass: outcome.pass,
129
+ results: outcome.results,
130
+ },
131
+ }, opts);
132
+ return {
133
+ epicId,
134
+ branch,
135
+ headSha,
136
+ pass: outcome.pass,
137
+ results: outcome.results,
138
+ eventId: stored.event_id,
139
+ ts: stored.record.ts,
140
+ };
141
+ }
@@ -0,0 +1,155 @@
1
+ // Does the cross-provider quorum have the providers crosscheck.yml says it
2
+ // has? Every other surface answers that question after the fact: a judge run
3
+ // that failed is a `judge-verdict` row with `ok: false`, and `smith stats
4
+ // providers` aggregates those rows into a rate. Both are readable only once
5
+ // the calls have already been spent.
6
+ //
7
+ // Two failures motivated this, and they are different from each other:
8
+ //
9
+ // 1. A provider that is `enabled: true` with its precondition unmet. The
10
+ // factory's own deepseek judge shipped `enabled: true` on a machine with
11
+ // no DEEPSEEK_API_KEY, so every quorum trigger — and gate.ts fires one on
12
+ // every blocking finding, unprompted — spawned a call that could not
13
+ // leave the machine, caught it, and wrote a failure row. Nothing was
14
+ // unsafe: the quorum is fail-closed and the finding stayed blocking. It
15
+ // was pure latency and a log that reads like a provider outage.
16
+ //
17
+ // 2. A promotion that cannot decide anything. `quorum_rule.min_providers`
18
+ // is 2, and on a gate finding the native provider is excluded as the
19
+ // finder, so the gating pool is the ACTIVE EXTERNALS alone. Promote one
20
+ // provider to `mode: active` and every case still comes back
21
+ // `insufficient-providers` — the operator has taken on the cost of a
22
+ // gating provider and bought no gating. docs/runbooks/providers.md §4
23
+ // states this in prose; until this check existed nothing asserted it.
24
+ //
25
+ // Deliberately not a judge call. A preflight that proves a provider answers
26
+ // by asking it a question costs what it is trying to save, and would turn a
27
+ // config check into a network dependency. This asks only what can be known
28
+ // locally: is the key set, is the binary on PATH, and does the arithmetic of
29
+ // the promotions add up.
30
+ import { readFileSync } from 'node:fs';
31
+ import { parseCrosscheckPolicy } from './crosscheck.js';
32
+ import { CROSSCHECK_POLICY_PATH } from './paths.js';
33
+ import { apiKeyPresent, commandOnPath } from './preconditions.js';
34
+ function inspect(config) {
35
+ if (config.kind === 'native') {
36
+ return {
37
+ provider: config.name,
38
+ kind: 'native',
39
+ transport: 'native',
40
+ enabled: config.enabled,
41
+ mode: 'native',
42
+ precondition: null,
43
+ status: 'ok',
44
+ detail: 'In-process. Nothing to configure and nothing to dial.',
45
+ };
46
+ }
47
+ const base = {
48
+ provider: config.name,
49
+ kind: config.kind,
50
+ enabled: config.enabled,
51
+ mode: config.mode,
52
+ };
53
+ if (config.transport === 'api') {
54
+ const set = apiKeyPresent(config.apiKeyEnv);
55
+ if (!config.enabled) {
56
+ return {
57
+ ...base,
58
+ transport: 'api',
59
+ precondition: config.apiKeyEnv,
60
+ status: 'not-applicable',
61
+ detail: config.enabledSource === 'auto'
62
+ ? `enabled: auto, and ${config.apiKeyEnv} is unset here, so this box does not use it. Set the key to switch it on; nothing to edit.`
63
+ : `Disabled in the policy, so ${config.apiKeyEnv} is not read.`,
64
+ };
65
+ }
66
+ return {
67
+ ...base,
68
+ transport: 'api',
69
+ precondition: config.apiKeyEnv,
70
+ status: set ? 'ok' : 'unmet',
71
+ detail: set
72
+ ? `${config.apiKeyEnv} is set.`
73
+ : `${config.apiKeyEnv} is unset or empty. Every quorum trigger will spend a call that cannot be sent, and record it as provider.missing-api-key.`,
74
+ };
75
+ }
76
+ const resolvable = commandOnPath(config.command);
77
+ if (!config.enabled) {
78
+ return {
79
+ ...base,
80
+ transport: 'cli',
81
+ precondition: config.command,
82
+ status: 'not-applicable',
83
+ detail: config.enabledSource === 'auto'
84
+ ? `enabled: auto, and ${config.command} is not on PATH here, so this box does not use it. Install it to switch it on; nothing to edit.`
85
+ : `Disabled in the policy, so ${config.command} is never spawned.`,
86
+ };
87
+ }
88
+ return {
89
+ ...base,
90
+ transport: 'cli',
91
+ precondition: config.command,
92
+ status: resolvable ? 'ok' : 'unmet',
93
+ detail: resolvable
94
+ ? `${config.command} is executable on PATH. Whether it is authenticated is not knowable without spending a call — run "smith judge run --provider ${config.name}" to find out.`
95
+ : `${config.command} is not executable on PATH. Every quorum trigger will spend a call that cannot start.`,
96
+ };
97
+ }
98
+ /**
99
+ * Read the policy as written and report what it would cost and buy. Pure
100
+ * except for `process.env` and PATH lookups; appends nothing and calls nobody.
101
+ */
102
+ export function judgePreflight(policyPath = CROSSCHECK_POLICY_PATH) {
103
+ // parseCrosscheckPolicy with `offline: false` rather than
104
+ // loadCrosscheckPolicy: see `offlineSwitch` above.
105
+ const policy = parseCrosscheckPolicy(readFileSync(policyPath, 'utf8'), {
106
+ offline: false,
107
+ });
108
+ const providers = Object.values(policy.providers)
109
+ .map(inspect)
110
+ .sort((a, b) => a.provider.localeCompare(b.provider));
111
+ const callable = providers.filter((p) => p.kind !== 'native' && p.enabled && p.status !== 'unmet');
112
+ const activeExternal = callable.filter((p) => p.mode === 'active').map((p) => p.provider);
113
+ const shadowExternal = callable.filter((p) => p.mode === 'shadow').map((p) => p.provider);
114
+ const minProviders = policy.quorumRule.minProviders;
115
+ const canDecide = activeExternal.length >= minProviders;
116
+ const problems = [];
117
+ const notes = [];
118
+ for (const p of providers) {
119
+ if (p.status !== 'unmet')
120
+ continue;
121
+ problems.push(`Provider "${p.provider}" is enabled but its precondition (${p.precondition}) is unmet. Set it, or set "enabled: false" for this runner.`);
122
+ }
123
+ // One promoted provider is the trap worth failing on. Zero is the shipped
124
+ // default and a deliberate one, so it is reported and not complained about.
125
+ //
126
+ // Unless it was chosen: the sentence below has always ended "or accept
127
+ // that these calls are shadow runs that cost like gating ones", and
128
+ // `accept_non_gating_actives` is where that acceptance is now written down.
129
+ // Declared, the same sentence is a note — the cost is unchanged and still
130
+ // printed, but a command that reports a decision the operator already made
131
+ // has no business exiting 1 on it, and a scheduled health check cannot
132
+ // distinguish a permanent 1 from a new one.
133
+ if (activeExternal.length > 0 && !canDecide) {
134
+ const arithmetic = `${activeExternal.length} external provider(s) are mode: active but quorum_rule.min_providers is ${minProviders}. On a finding the native provider raised it is excluded as the finder, so the gating pool is the actives alone and every case returns insufficient-providers.`;
135
+ if (policy.quorumRule.acceptNonGatingActives) {
136
+ notes.push(`${arithmetic} Accepted via quorum_rule.accept_non_gating_actives: the verdict still reaches the operator on the escalation, and enabling a second provider starts gating with no further edit.`);
137
+ }
138
+ else {
139
+ problems.push(`${arithmetic} Promote a second provider or accept that these calls are shadow runs that cost like gating ones.`);
140
+ }
141
+ }
142
+ const detail = canDecide
143
+ ? `${activeExternal.length} active external provider(s) against min_providers ${minProviders}: a native finding can be overturned.`
144
+ : activeExternal.length === 0
145
+ ? `No external provider is mode: active. Every cross-check is recorded and none can change an outcome — the shipped default.`
146
+ : `${activeExternal.length} active external provider(s) against min_providers ${minProviders}: nothing can be decided.`;
147
+ return {
148
+ policyPath,
149
+ offlineSwitch: Boolean(process.env.SMITH_CROSSCHECK_OFFLINE),
150
+ providers,
151
+ gating: { activeExternal, shadowExternal, minProviders, canDecide, detail },
152
+ problems,
153
+ notes,
154
+ };
155
+ }
@@ -0,0 +1,310 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { JUDGE_REPORT_EVENT_TYPE } from './agents-registry.js';
3
+ import { SmithError } from './errors.js';
4
+ import { appendEvent, eventTaskId, readLineageEvents, } from './events.js';
5
+ import { isQualifiedTaskId, taskIdsMatch } from './taskId.js';
6
+ export class JudgeError extends SmithError {
7
+ }
8
+ /**
9
+ * A dispatched judge must report back, and the report is a file (D-31, D-20 /
10
+ * P9-11).
11
+ *
12
+ * Wave 3 dispatched eight agents and five ended their turn on an announcement
13
+ * of the next step they were about to take — "Now let's run the
14
+ * prototype-pollution probes" — signalled `completed` to the layer above, and
15
+ * wrote nothing. All five finished correctly on one resume, in 1–13 tool
16
+ * calls, so the work was cheap and the silence was the entire defect. Wave 4
17
+ * showed the sharper version: a reviewer that returned 36k tokens of fluent,
18
+ * on-topic, technically accurate prose which was a fragment of its own
19
+ * planning. An empty return is detectable. A plausible fragment is not.
20
+ *
21
+ * So completion here is never "the agent said something". It is "the artifact
22
+ * exists and parses" — checked by this module, against a path the DISPATCH
23
+ * declared in advance, so nobody gets to pick the finish line after the fact.
24
+ *
25
+ * The two halves are ordinary events, which is what lets anything downstream
26
+ * notice a gap:
27
+ *
28
+ * - dispatch: a `dispatch_decision` carrying `declared_artifact` and
29
+ * `round` on top of the three dimensions taxonomy.yml already requires of
30
+ * a dispatch. It is a normal dispatch record — agents-registry.ts folds it
31
+ * unchanged — and `declared_artifact` is what marks it as a turn that owes
32
+ * a report. A coder's dispatch declares no artifact and is not a judge.
33
+ * - report: a `judge-reported` carrying `agent_role`, `round`,
34
+ * `artifact_path` and `finding_count`.
35
+ *
36
+ * The difference between the two sets is `outstandingJudges()`, and the gate
37
+ * refuses to score a task while it is non-empty.
38
+ */
39
+ export const JUDGE_DISPATCH_EVENT_TYPE = 'dispatch_decision';
40
+ /**
41
+ * Re-exported, not re-declared. The registry owns the terminal-event
42
+ * vocabulary — this one next to `task-result-recorded` and `error-logged`,
43
+ * which are the other two ways a turn can end — and a judge report is one of
44
+ * its three. Two `= 'judge-reported'` literals in two modules is two places to
45
+ * keep in sync, and the day they disagree the registry stops recognising the
46
+ * report this file writes.
47
+ */
48
+ export { JUDGE_REPORT_EVENT_TYPE };
49
+ /**
50
+ * Every judge dispatched from the operator session runs on the native provider
51
+ * at its top tier unless the caller says otherwise.
52
+ *
53
+ * The concrete model gets no such default (P9-23). Provider and tier are coarse
54
+ * enough that a wrong default is visibly wrong; a model id is not, and this
55
+ * verb is exactly how crosscheck.yml's finder_ne_critic pair (reviewer /
56
+ * verifier) reaches the log. `smith dispatch check` compares those two ids, so
57
+ * a defaulted one would have it compare two placeholders and report on a
58
+ * session nobody ran.
59
+ */
60
+ const DEFAULT_PROVIDER = 'claude';
61
+ const DEFAULT_MODEL_TIER = 'frontier';
62
+ /** `--no-findings`: an operator attesting that a judge ran clean, recorded as an attestation and never as an artifact. */
63
+ const ATTESTED_BY = 'operator';
64
+ /**
65
+ * The turn a record belongs to, tolerating the two spellings of one task id.
66
+ *
67
+ * Both halves of a turn are stamped with whatever the operator typed:
68
+ * `smith judge dispatch --task` and `smith gate run <task-id>` pass their
69
+ * argument through verbatim, and neither qualifies it. Keyed on the raw
70
+ * string, a dispatch recorded as `epic-1/task-1` and a gate run asking for
71
+ * `task-1` were two different turns, so the fold handed back an empty set —
72
+ * which `outstandingJudges` cannot tell apart from "every judge reported"
73
+ * (D-183).
74
+ *
75
+ * A bare id that two epics both claim matches more than one turn, and this
76
+ * returns none of them: closing one epic's judge with the other's report is
77
+ * the failure this guard exists to prevent, and `buildTaskIdAliases` refuses
78
+ * exactly the same ambiguity. Leaving both outstanding blocks the gate, and
79
+ * the operator's remedy is to qualify the id.
80
+ */
81
+ function findTurn(turns, taskId, role) {
82
+ const matching = turns.filter((t) => t.role === role && taskIdsMatch(t.taskId, taskId));
83
+ return matching.length === 1 ? matching[0] : undefined;
84
+ }
85
+ function stringField(payload, field) {
86
+ const value = payload[field];
87
+ return typeof value === 'string' && value !== '' ? value : undefined;
88
+ }
89
+ function numberField(payload, field) {
90
+ const value = payload[field];
91
+ return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
92
+ }
93
+ /**
94
+ * Fold the dispatch set and the report set into one turn per (task, role).
95
+ *
96
+ * Two passes, not one, because the log's order is real: a round-1 report that
97
+ * lands after a round-2 dispatch must NOT close round 2. Pass one settles
98
+ * which round each role is on; pass two closes only a report that names that
99
+ * round. The alternative — a single pass that resets `reported` on each new
100
+ * dispatch — gets the same answer for a well-ordered log and the wrong one for
101
+ * a log where a slow judge's report arrives late.
102
+ *
103
+ * A role's earlier rounds are superseded rather than kept outstanding: the
104
+ * same rule agents-registry.ts already applies to a re-dispatched task, and
105
+ * without it every re-poke would leave a permanent phantom in the outstanding
106
+ * list.
107
+ */
108
+ export function foldJudgeTurns(events, taskId) {
109
+ const turns = [];
110
+ for (const { record } of events) {
111
+ if (record.event_type !== JUDGE_DISPATCH_EVENT_TYPE)
112
+ continue;
113
+ // Both levels: the dispatch that owes a report may name its task only in
114
+ // its payload (D-245), and a turn opened under no task is a report nobody
115
+ // can ever close.
116
+ const recordTaskId = eventTaskId(record);
117
+ if (recordTaskId === null)
118
+ continue;
119
+ if (taskId !== undefined && !taskIdsMatch(recordTaskId, taskId))
120
+ continue;
121
+ const role = stringField(record.payload, 'agent_role');
122
+ const declaredArtifact = stringField(record.payload, 'declared_artifact');
123
+ const round = numberField(record.payload, 'round');
124
+ // No declared artifact, no report owed: this is a coder or a merger, not
125
+ // a judge. `round` is required alongside it, so a half-formed payload is
126
+ // ignored rather than folded into a turn nobody can ever close.
127
+ if (role === undefined || declaredArtifact === undefined || round === undefined)
128
+ continue;
129
+ const existing = findTurn(turns, recordTaskId, role);
130
+ if (existing !== undefined) {
131
+ if (existing.round > round)
132
+ continue;
133
+ // The qualified spelling wins the row it names: it is the one a caller
134
+ // can hand back to anything that needs the epic.
135
+ existing.taskId = isQualifiedTaskId(existing.taskId) ? existing.taskId : recordTaskId;
136
+ existing.round = round;
137
+ existing.declaredArtifact = declaredArtifact;
138
+ existing.reported = false;
139
+ existing.reportedArtifact = null;
140
+ existing.attested = false;
141
+ continue;
142
+ }
143
+ turns.push({
144
+ taskId: recordTaskId,
145
+ role,
146
+ round,
147
+ declaredArtifact,
148
+ reported: false,
149
+ reportedArtifact: null,
150
+ attested: false,
151
+ });
152
+ }
153
+ for (const { record } of events) {
154
+ if (record.event_type !== JUDGE_REPORT_EVENT_TYPE)
155
+ continue;
156
+ const recordTaskId = eventTaskId(record);
157
+ if (recordTaskId === null)
158
+ continue;
159
+ if (taskId !== undefined && !taskIdsMatch(recordTaskId, taskId))
160
+ continue;
161
+ const role = stringField(record.payload, 'agent_role');
162
+ if (role === undefined)
163
+ continue;
164
+ const turn = findTurn(turns, recordTaskId, role);
165
+ if (turn === undefined || turn.round !== numberField(record.payload, 'round'))
166
+ continue;
167
+ turn.reported = true;
168
+ turn.reportedArtifact = stringField(record.payload, 'artifact_path') ?? null;
169
+ turn.attested = record.payload.attested_by !== undefined && record.payload.attested_by !== null;
170
+ }
171
+ return [...turns].sort((a, b) => a.role.localeCompare(b.role));
172
+ }
173
+ /** The judges a task is still waiting on — the whole point of recording either half. */
174
+ export function outstandingJudges(turns) {
175
+ return turns.filter((t) => !t.reported);
176
+ }
177
+ /**
178
+ * Read the turns for one task out of its session's whole lineage.
179
+ *
180
+ * The lineage and not the session, because a judge turn is a promise with two
181
+ * halves and nothing makes them land in the same operator session. An epic
182
+ * that outgrows one session is the recommended shape (P9-7), so round 1
183
+ * dispatches the reviewer and round 2 is where the report — and the gate that
184
+ * reads this — arrives. Folding one session broke that in both directions:
185
+ * `recordJudgeReport` refused a real report as `judges.not-dispatched`, and
186
+ * `outstandingJudges` came back empty, which is the gate scoring a task whose
187
+ * judge never reported. That is precisely the state P9-11 exists to refuse.
188
+ *
189
+ * D-119 swept every deciding fold onto `readLineageEvents` and missed this
190
+ * one: the sweep found its callers by grep, and this file carried a NUL byte
191
+ * that made grep skip it in silence (D-155). D-156 is the half that survived.
192
+ */
193
+ export async function readJudgeTurns(taskId, ctx, opts = {}) {
194
+ return foldJudgeTurns(await readLineageEvents(ctx.sessionId, opts), taskId);
195
+ }
196
+ async function emit(eventType, payload, taskId, ctx, opts) {
197
+ return appendEvent({
198
+ session_id: ctx.sessionId,
199
+ actor: ctx.actor ?? 'system',
200
+ event_type: eventType,
201
+ task_id: taskId,
202
+ plan_version: ctx.planVersion,
203
+ causal_parent: ctx.causalParent,
204
+ payload,
205
+ }, opts);
206
+ }
207
+ /**
208
+ * Record that a judge was dispatched and what file it owes. Emitted by the
209
+ * dispatcher BEFORE the agent runs — a dispatch recorded afterwards could only
210
+ * ever describe judges that came back, which is the set that was never the
211
+ * problem.
212
+ */
213
+ export async function recordJudgeDispatch(input, ctx, opts = {}) {
214
+ if (!Number.isInteger(input.round) || input.round < 1) {
215
+ throw new JudgeError('judges.invalid-round', `Judge rounds are 1-based integers; got ${JSON.stringify(input.round)}.`, { round: input.round, task_id: input.taskId, agent_role: input.role });
216
+ }
217
+ if (input.artifactPath.trim() === '') {
218
+ throw new JudgeError('judges.no-declared-artifact', 'A judge dispatch must declare the artifact it will write — that path is the finish line.', { task_id: input.taskId, agent_role: input.role });
219
+ }
220
+ // appendEvent would reject this too, on taxonomy grounds. Caught here anyway,
221
+ // so the message names the judge that was about to be dispatched instead of
222
+ // the record type that failed validation.
223
+ if (typeof input.model !== 'string' || input.model.trim() === '') {
224
+ throw new JudgeError('judges.no-model', `A judge dispatch must name the model it runs on: "${input.role}" is checked against the finder it followed by model id, not by tier (P9-23).`, { task_id: input.taskId, agent_role: input.role });
225
+ }
226
+ return emit(JUDGE_DISPATCH_EVENT_TYPE, {
227
+ agent_role: input.role,
228
+ provider: input.provider ?? DEFAULT_PROVIDER,
229
+ model_tier: input.modelTier ?? DEFAULT_MODEL_TIER,
230
+ model: input.model,
231
+ round: input.round,
232
+ declared_artifact: input.artifactPath,
233
+ }, input.taskId, ctx, opts);
234
+ }
235
+ /**
236
+ * Read a judge's artifact and answer how many findings it holds.
237
+ *
238
+ * Three distinct failures, three distinct codes, because they are three
239
+ * different things to do about it: the file is not there (re-poke the agent),
240
+ * it is there and is prose (the agent narrated instead of reporting), it is
241
+ * there and parses but is not a findings list (the agent wrote some other
242
+ * shape — a grader verdict, say, which is P9-14's business and needs its own
243
+ * schema before anything here can accept it).
244
+ */
245
+ export function readJudgeArtifact(artifactPath) {
246
+ let raw;
247
+ try {
248
+ raw = readFileSync(artifactPath, 'utf8');
249
+ }
250
+ catch (err) {
251
+ throw new JudgeError('judges.artifact-missing', `Judge artifact "${artifactPath}" is not on disk. A judge whose turn ended without its declared file did not report; re-run it rather than scoring without it.`, { artifact_path: artifactPath, cause: err instanceof Error ? err.message : String(err) });
252
+ }
253
+ let parsed;
254
+ try {
255
+ parsed = JSON.parse(raw);
256
+ }
257
+ catch (err) {
258
+ throw new JudgeError('judges.artifact-unparseable', `Judge artifact "${artifactPath}" is not JSON. Prose in the artifact slot is the wave-4 failure exactly: fluent text that reads like a verdict and is not one.`, { artifact_path: artifactPath, cause: err instanceof Error ? err.message : String(err) });
259
+ }
260
+ if (!Array.isArray(parsed)) {
261
+ throw new JudgeError('judges.artifact-not-a-list', `Judge artifact "${artifactPath}" parsed to ${parsed === null ? 'null' : typeof parsed}, not a findings-evidence array. An empty review is "[]", written out.`, { artifact_path: artifactPath });
262
+ }
263
+ return parsed;
264
+ }
265
+ function notDispatchedMessage(input, turn, forRole) {
266
+ if (forRole.length > 1) {
267
+ return `Task id "${input.taskId}" names a "${input.role}" turn in ${forRole.length} epics (${forRole.map((t) => t.taskId).join(', ')}). Report against the qualified id — closing one of them here would be a guess.`;
268
+ }
269
+ if (turn === undefined) {
270
+ return `No judge dispatch for role "${input.role}" on ${input.taskId}. Record the dispatch first — a report with no dispatch behind it proves nothing about coverage.`;
271
+ }
272
+ return `Role "${input.role}" on ${input.taskId} is on round ${turn.round}, not round ${input.round}.`;
273
+ }
274
+ /**
275
+ * Close a judge turn. Refuses unless the role was actually dispatched for the
276
+ * round being reported, so a report cannot invent its own dispatch and make
277
+ * the two sets agree by growing both.
278
+ */
279
+ export async function recordJudgeReport(input, ctx, opts = {}) {
280
+ const turns = await readJudgeTurns(input.taskId, ctx, opts);
281
+ const forRole = turns.filter((t) => t.role === input.role);
282
+ // More than one is a bare id that two epics both claim a turn for. Closing
283
+ // either would be a guess, and `foldJudgeTurns` refuses the same ambiguity,
284
+ // so the report emitted here would close nothing on the next read anyway.
285
+ const turn = forRole.length === 1 ? forRole[0] : undefined;
286
+ if (turn === undefined || (input.round !== undefined && input.round !== turn.round)) {
287
+ throw new JudgeError('judges.not-dispatched', notDispatchedMessage(input, turn, forRole), {
288
+ task_id: input.taskId,
289
+ agent_role: input.role,
290
+ round: input.round ?? null,
291
+ });
292
+ }
293
+ const artifactPath = input.noFindings ? null : (input.artifactPath ?? turn.declaredArtifact);
294
+ const findingCount = artifactPath === null ? 0 : readJudgeArtifact(artifactPath).length;
295
+ await emit(JUDGE_REPORT_EVENT_TYPE, {
296
+ agent_role: input.role,
297
+ round: turn.round,
298
+ artifact_path: artifactPath,
299
+ finding_count: findingCount,
300
+ ...(input.noFindings ? { attested_by: ATTESTED_BY } : {}),
301
+ }, input.taskId, ctx, opts);
302
+ return {
303
+ taskId: input.taskId,
304
+ role: input.role,
305
+ round: turn.round,
306
+ artifactPath,
307
+ findingCount,
308
+ attested: input.noFindings === true,
309
+ };
310
+ }