@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,396 @@
1
+ // factory/policies/crosscheck.yml loader (architecture §6 "Multi-provider
2
+ // cross-check"). Same parse-with-defaults pattern as scheduler.ts's
3
+ // parseSchedulerPolicy()/loadSchedulerPolicy() — a RawXYaml shape for the
4
+ // on-disk snake_case document, mapped onto a typed camelCase policy.
5
+ //
6
+ // `kind: native` (claude) has no transport at all — it's the in-process
7
+ // judge already wired through gate.ts/findings.ts, never dispatched through
8
+ // providers/index.ts. Every other provider declares a `transport` field
9
+ // (Phase 8) that selects which transport module (cli-transport.ts /
10
+ // api-transport.ts) runs it — whatever the provider is called, since nothing
11
+ // downstream dispatches on the name. `kind: api` is kept on those for
12
+ // backward compatibility with the Phase-1 scaffold (it predates the transport
13
+ // split and no longer carries meaning on its own — `transport` is now the
14
+ // field that actually selects behavior).
15
+ import { readFileSync } from 'node:fs';
16
+ import { parse as parseYaml } from 'yaml';
17
+ import { SmithError } from './errors.js';
18
+ import { CROSSCHECK_POLICY_PATH } from './paths.js';
19
+ import { apiKeyPresent, commandOnPath } from './preconditions.js';
20
+ export class CrosscheckError extends SmithError {
21
+ }
22
+ /**
23
+ * The concrete model a judge dispatch ran on, for the event log's required
24
+ * `model` field. An api-transport provider declares it; a cli-transport one
25
+ * usually doesn't, because the binary picks — `<command>:default` records
26
+ * that honestly rather than inventing a model id nobody verified.
27
+ */
28
+ export function providerModel(config) {
29
+ if (config.kind === 'native')
30
+ return `${config.name}:native`;
31
+ if (config.transport === 'cli')
32
+ return config.model ?? `${config.command}:default`;
33
+ return config.model;
34
+ }
35
+ // Mirrors the shipped crosscheck.yml asymmetric_roles.pairs block, which in
36
+ // turn mirrors two sentences already in the role templates: spec-reviewer
37
+ // "never runs on the planner's own model", verifier "never runs on the
38
+ // reviewer's own model".
39
+ const DEFAULT_ASYMMETRIC_PAIRS = [
40
+ { finder: 'planner', critic: 'spec-reviewer' },
41
+ { finder: 'reviewer', critic: 'verifier' },
42
+ ];
43
+ // Mirrors the shipped crosscheck.yml role_isolation.pairs block. One entry,
44
+ // and it is meant to stay one until a second role starts grading work it
45
+ // could have written: every other grader in the pipeline is a judge, and a
46
+ // judge cannot write at all (`role_write_scopes`, docs/standards/guardrails.md).
47
+ const DEFAULT_ROLE_ISOLATION_PAIRS = [
48
+ { worker: 'coder', auditor: 'tester' },
49
+ ];
50
+ // Mirrors the shipped crosscheck.yml independent_finder block. Every default
51
+ // here is the OFF position: a file that omits the block entirely gets a finder
52
+ // that is disabled, names nobody, gates nothing if enabled, and refuses to send
53
+ // a diff. The only way to any of the four powers is an operator writing it down.
54
+ //
55
+ // `providers` is empty rather than a name because this file is the fallback for
56
+ // a policy that did not say, and nothing here knows which vendors a given box
57
+ // has. A default naming one would dispatch a CLI the operator may never have
58
+ // installed, and then report `independent_finder.providers names "<vendor>"`
59
+ // back to someone who named no vendor at all. Empty says the true thing: nobody
60
+ // was asked. runIndependentFinder() refuses on it in those words.
61
+ const DEFAULT_INDEPENDENT_FINDER_PROVIDERS = [];
62
+ const DEFAULT_INDEPENDENT_FINDER_MAX_DIFF_BYTES = 120_000;
63
+ const DEFAULT_MODEL_TIER = 'mid'; // taxonomy.yml model_tier — judges run sonnet-tier per architecture §4, external judges default to the same tier absent an override.
64
+ // Defaults mirror the checked-in crosscheck.yml plan_quorum block exactly,
65
+ // so a hand-authored yaml that omits the block (e.g. in tests) still gets
66
+ // the same triggers the shipped policy defines.
67
+ const DEFAULT_PLAN_QUORUM_BUDGET_RATIO = 0.5;
68
+ const DEFAULT_PLAN_QUORUM_CONFIDENCE_THRESHOLD = 0.8;
69
+ const DEFAULT_PLAN_QUORUM_SECURITY_CASES = ['infra'];
70
+ const DEFAULT_PLAN_QUORUM_SECURITY_ROLES = ['security-reviewer'];
71
+ const DEFAULT_PLAN_QUORUM_SECURITY_KEYWORDS = [
72
+ 'auth',
73
+ 'authz',
74
+ 'authentication',
75
+ 'authorization',
76
+ 'credential',
77
+ 'secret',
78
+ 'token',
79
+ 'password',
80
+ 'crypto',
81
+ 'encryption',
82
+ 'tls',
83
+ 'injection',
84
+ 'xss',
85
+ 'csrf',
86
+ 'sandbox',
87
+ 'permission',
88
+ 'privilege',
89
+ 'sanitize',
90
+ 'escalation',
91
+ ];
92
+ /**
93
+ * Every knob this file hands to a comparison, checked rather than trusted --
94
+ * the same guard scheduler.ts carries for scheduler.yml, kept local because
95
+ * the two policies raise different error codes and default differently.
96
+ *
97
+ * `??` fills in on null/undefined only, so a YAML typo arrived at the
98
+ * comparison as itself while the declared type still said `number`. Nothing
99
+ * threw. The gate simply decided something else:
100
+ *
101
+ * - `min_providers: two` -- `1 < 'two'` is false, so the
102
+ * `insufficient-providers` escalation never fires and one judge returns a
103
+ * decided `1-of-1` for a gate the policy says needs two.
104
+ * - `budget_ratio: half` -- `totalTokens >= NaN` is false, so trigger 1
105
+ * never fires however much of the epic cap the plan claims.
106
+ * - `confidence_threshold: high` -- `0.1 < 'high'` is false, so trigger 3
107
+ * never fires however unsure the planner says it is.
108
+ *
109
+ * The list knobs fail differently again, because a spread over a bare scalar
110
+ * is a spread over its characters, not a one-element list:
111
+ *
112
+ * - `security_cases: infra` becomes ['i','n','f','r','a'] and the cases are
113
+ * matched with `Set.has`, so a task whose case IS infra stops tripping
114
+ * trigger 2. Same for `security_roles`.
115
+ * - `security_keywords: auth` becomes ['a','u','t','h'] and keywords match
116
+ * by substring, so "render a nice table" trips it instead. One typo
117
+ * silences two arms of trigger 2 and floods the third.
118
+ *
119
+ * The booleans fail a third way, and `enabled` is the one that matters. The
120
+ * `yaml` package is YAML 1.2, where only `false`/`False`/`FALSE` are
121
+ * booleans: `no`, `off`, `n` and a quoted `"false"` all arrive as non-empty
122
+ * strings, `??` does not fire on them, and all three readers ask the field by
123
+ * truthiness (quorum.ts's `enabledExternalProviders` and `runQuorum`,
124
+ * providers/index.ts's `runJudge`). So the operator who followed the runbook's
125
+ * rollback step -- "`enabled: false` means never invoked at all" -- and wrote
126
+ * it the YAML 1.1 way watched the provider keep being dispatched: prompts
127
+ * still leaving the machine, spend still accruing, `provider.disabled` never
128
+ * raised. That is the same silence as the others pointed at the one switch
129
+ * whose whole purpose is to stop something from happening.
130
+ *
131
+ * `asymmetric_roles.pairs` is a list of maps rather than of scalars, and so
132
+ * fails a fourth way: an entry missing either key -- `critics:` is one
133
+ * keystroke from `critic:` -- used to be filtered out of the list instead of
134
+ * refused, and `[] ?? DEFAULT` keeps an emptied list rather than the
135
+ * defaults. dispatchAudit walks the pairs that survived, finds nothing wrong
136
+ * with them, and reports `ok` over a shorter list than the file declares. The
137
+ * pair most likely to go missing that way is the verifier's own rule -- "never
138
+ * runs on the reviewer's own model" -- so a verifier dispatched on the
139
+ * reviewer's model passes an audit that never looked at it.
140
+ *
141
+ * Four of those five fail toward "no quorum needed", on a gate whose whole
142
+ * job is to demand a second opinion; the boolean fails toward "call out".
143
+ * A wrong *number* still passes, as in scheduler.ts: it changes what the gate
144
+ * demands, in the open, where the operator reads it. A wrong *type* changes it
145
+ * in silence.
146
+ */
147
+ const DECIDES_DIFFERENTLY = 'A value the comparison cannot read does not fail the gate, it changes what the gate decides.';
148
+ function quorumNumber(field, value) {
149
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
150
+ throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a finite number; got ${JSON.stringify(value)}. ${DECIDES_DIFFERENTLY}`, { field, value });
151
+ }
152
+ return value;
153
+ }
154
+ function quorumBoolean(field, value) {
155
+ if (typeof value !== 'boolean') {
156
+ throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be true or false; got ${JSON.stringify(value)}. This file is YAML 1.2, where no/off/yes/on are strings, and a non-empty string is truthy. On an external provider's enabled the one other legal value is auto, unquoted and lowercase, which resolves against that provider's precondition on the box reading the file. ${DECIDES_DIFFERENTLY}`, { field, value });
157
+ }
158
+ return value;
159
+ }
160
+ /**
161
+ * `enabled` reads one value more than the other booleans do. `auto` means
162
+ * "ask this box": the parser resolves it against the same precondition
163
+ * `smith judge preflight` reports on, so a policy that names a judge no box
164
+ * has to have is legal to ship, and silent where it cannot run.
165
+ *
166
+ * Only the exact lowercase word. `Auto` and a quoted `"auto"` are refused
167
+ * with everything else, because the whole reason this field is checked at all
168
+ * is that a near-miss here used to keep a judge switched on.
169
+ */
170
+ function parseEnabled(field, value) {
171
+ if (value === 'auto')
172
+ return 'auto';
173
+ return quorumBoolean(field, value);
174
+ }
175
+ function quorumString(field, value) {
176
+ if (typeof value !== 'string') {
177
+ throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a string; got ${JSON.stringify(value)}. ${DECIDES_DIFFERENTLY}`, { field, value });
178
+ }
179
+ return value;
180
+ }
181
+ /** Copied, not aliased: two parses must not share one array, or a caller mutating its policy would rewrite the defaults for every later parse. */
182
+ function quorumStringList(field, value) {
183
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
184
+ throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a list of strings; got ${JSON.stringify(value)}. A bare scalar spreads into its characters, which match nothing -- or, for keywords, nearly everything.`, { field, value });
185
+ }
186
+ return [...value];
187
+ }
188
+ /**
189
+ * The pair list, rebuilt entry by entry rather than filtered.
190
+ *
191
+ * A half-readable list is the same hole as no list: keeping only the entries
192
+ * that parsed would hand dispatchAudit a shorter set of rules than the
193
+ * operator wrote and let it report `ok` over the remainder. An empty list
194
+ * stays legal -- dispatchAudit answers it with one `unverifiable` check and
195
+ * `ok: false`, so it is a sentence the operator can read back -- and only a
196
+ * shape no comparison can read is refused. Copied, not aliased, for the same
197
+ * reason quorumStringList() is.
198
+ */
199
+ function quorumPairs(field, value) {
200
+ if (!Array.isArray(value) || !value.every(isRolePair)) {
201
+ throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a list of { finder, critic } maps naming two roles; got ${JSON.stringify(value)}. A pair the audit cannot read leaves the rule it names unchecked, and the audit still reports ok. ${DECIDES_DIFFERENTLY}`, { field, value });
202
+ }
203
+ return value.map((pair) => ({ finder: pair.finder, critic: pair.critic }));
204
+ }
205
+ function isRolePair(entry) {
206
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
207
+ return false;
208
+ const { finder, critic } = entry;
209
+ return typeof finder === 'string' && finder !== '' && typeof critic === 'string' && critic !== '';
210
+ }
211
+ function parsePlanQuorum(raw) {
212
+ return {
213
+ budgetRatio: quorumNumber('plan_quorum.budget_ratio', raw?.budget_ratio ?? DEFAULT_PLAN_QUORUM_BUDGET_RATIO),
214
+ confidenceThreshold: quorumNumber('plan_quorum.confidence_threshold', raw?.confidence_threshold ?? DEFAULT_PLAN_QUORUM_CONFIDENCE_THRESHOLD),
215
+ securityCases: quorumStringList('plan_quorum.security_cases', raw?.security_cases ?? DEFAULT_PLAN_QUORUM_SECURITY_CASES),
216
+ securityRoles: quorumStringList('plan_quorum.security_roles', raw?.security_roles ?? DEFAULT_PLAN_QUORUM_SECURITY_ROLES),
217
+ securityKeywords: quorumStringList('plan_quorum.security_keywords', raw?.security_keywords ?? DEFAULT_PLAN_QUORUM_SECURITY_KEYWORDS),
218
+ };
219
+ }
220
+ function parseProvider(name, raw) {
221
+ // `enabled` is checked before anything else this function does, because it
222
+ // is the field that decides whether the rest of the config is ever used.
223
+ const declared = parseEnabled(`providers.${name}.enabled`, raw.enabled ?? false);
224
+ const enabledSource = declared === 'auto' ? 'auto' : 'declared';
225
+ if (raw.kind === 'native') {
226
+ if (declared === 'auto') {
227
+ throw new CrosscheckError('crosscheck.invalid-provider', `crosscheck.yml providers.${name}.enabled is auto, but "${name}" is kind: native — it runs in this process and has no precondition to ask about. Write true or false.`, { provider: name, field: `providers.${name}.enabled` });
228
+ }
229
+ return { name, kind: 'native', enabled: declared };
230
+ }
231
+ // `mode` needs no check: anything that is not exactly 'active' becomes
232
+ // 'shadow', which is the safe direction for it -- a verdict that gates
233
+ // nothing. Left as it is, and named here so the asymmetry reads as chosen.
234
+ const mode = raw.mode === 'active' ? 'active' : 'shadow';
235
+ const modelTier = quorumString(`providers.${name}.model_tier`, raw.model_tier ?? DEFAULT_MODEL_TIER);
236
+ if (raw.transport === 'cli') {
237
+ if (!raw.command) {
238
+ throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" has transport: cli but no command.`, { provider: name });
239
+ }
240
+ const command = quorumString(`providers.${name}.command`, raw.command);
241
+ return {
242
+ name,
243
+ kind: 'api',
244
+ transport: 'cli',
245
+ // `auto` asks the same question judgePreflight reports on: is the
246
+ // binary runnable here. Not whether it is authenticated -- only a real
247
+ // call knows that, so `auto` can still resolve true on a box where
248
+ // `codex login` was never run, and the quorum records the failure.
249
+ enabled: declared === 'auto' ? commandOnPath(command) : declared,
250
+ enabledSource,
251
+ mode,
252
+ modelTier,
253
+ command,
254
+ // spawn(command, 'exec') throws ERR_INVALID_ARG_TYPE at dispatch, which
255
+ // quorum.ts catches and files as a provider error -- so the judge drops
256
+ // out of the quorum and the run reads as if the policy said so.
257
+ args: quorumStringList(`providers.${name}.args`, raw.args ?? []),
258
+ ...(raw.model ? { model: raw.model } : {}),
259
+ };
260
+ }
261
+ if (raw.transport === 'api') {
262
+ if (!raw.base_url || !raw.model || !raw.api_key_env) {
263
+ throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" has transport: api but is missing base_url/model/api_key_env.`, { provider: name });
264
+ }
265
+ const apiKeyEnv = quorumString(`providers.${name}.api_key_env`, raw.api_key_env);
266
+ return {
267
+ name,
268
+ kind: 'api',
269
+ transport: 'api',
270
+ // Set and non-empty, which is all that is knowable without spending a
271
+ // call; whether the key is VALID is not.
272
+ enabled: declared === 'auto' ? apiKeyPresent(apiKeyEnv) : declared,
273
+ enabledSource,
274
+ mode,
275
+ modelTier,
276
+ baseUrl: quorumString(`providers.${name}.base_url`, raw.base_url),
277
+ model: quorumString(`providers.${name}.model`, raw.model),
278
+ apiKeyEnv,
279
+ responseFormatJsonObject: quorumBoolean(`providers.${name}.response_format_json_object`, raw.response_format_json_object ?? true),
280
+ };
281
+ }
282
+ throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" is not kind: native and has no recognized transport (got "${String(raw.transport)}").`, { provider: name, transport: raw.transport });
283
+ }
284
+ /**
285
+ * The isolation pair list, rebuilt entry by entry for quorumPairs()' reason:
286
+ * a half-readable list is the same hole as no list. An empty list stays legal
287
+ * and testerAudit answers it with one `unverifiable` check.
288
+ */
289
+ function isolationPairs(field, value) {
290
+ if (!Array.isArray(value) || !value.every(isIsolationPair)) {
291
+ throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a list of { worker, auditor } maps naming two roles; got ${JSON.stringify(value)}. A pair the audit cannot read leaves the rule it names unchecked, and the audit still reports ok. ${DECIDES_DIFFERENTLY}`, { field, value });
292
+ }
293
+ return value.map((pair) => ({ worker: pair.worker, auditor: pair.auditor }));
294
+ }
295
+ function isIsolationPair(entry) {
296
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
297
+ return false;
298
+ const { worker, auditor } = entry;
299
+ return (typeof worker === 'string' && worker !== '' && typeof auditor === 'string' && auditor !== '');
300
+ }
301
+ function parseRoleIsolation(raw) {
302
+ return {
303
+ // Copied, not aliased — same reason as parsePlanQuorum() above.
304
+ pairs: isolationPairs('role_isolation.pairs', raw?.pairs ?? DEFAULT_ROLE_ISOLATION_PAIRS.map((p) => ({ ...p }))),
305
+ };
306
+ }
307
+ /**
308
+ * `severity_resolution` gets a closed check where `mode` gets none, and the
309
+ * asymmetry is the point. An unreadable `mode` falls to `shadow`, which is the
310
+ * safe direction — a verdict that gates nothing. An unreadable
311
+ * `severity_resolution` has no safe direction: defaulting to `native-wins`
312
+ * silently discards the escalation the operator asked for, and defaulting to
313
+ * `highest-wins` silently grants one they did not. So it is refused.
314
+ */
315
+ function parseSeverityResolution(value) {
316
+ if (value === 'highest-wins' || value === 'native-wins')
317
+ return value;
318
+ throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml independent_finder.severity_resolution must be "highest-wins" or "native-wins"; got ${JSON.stringify(value)}. ${DECIDES_DIFFERENTLY}`, { field: 'independent_finder.severity_resolution', value });
319
+ }
320
+ function parseIndependentFinder(raw) {
321
+ const maxDiffBytes = quorumNumber('independent_finder.max_diff_bytes', raw?.max_diff_bytes ?? DEFAULT_INDEPENDENT_FINDER_MAX_DIFF_BYTES);
322
+ if (!Number.isInteger(maxDiffBytes) || maxDiffBytes <= 0) {
323
+ throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml independent_finder.max_diff_bytes must be a positive integer; got ${JSON.stringify(maxDiffBytes)}. A cap of zero or less refuses every diff, which reads in the log exactly like a finder that ran and found nothing.`, { field: 'independent_finder.max_diff_bytes', value: maxDiffBytes });
324
+ }
325
+ return {
326
+ enabled: quorumBoolean('independent_finder.enabled', raw?.enabled ?? false),
327
+ // Same fall-to-shadow rule as a provider's own `mode`, for the same reason.
328
+ mode: raw?.mode === 'active' ? 'active' : 'shadow',
329
+ // Copied, not aliased — same reason as parsePlanQuorum() above.
330
+ providers: quorumStringList('independent_finder.providers', raw?.providers ?? DEFAULT_INDEPENDENT_FINDER_PROVIDERS),
331
+ sendDiff: quorumBoolean('independent_finder.send_diff', raw?.send_diff ?? false),
332
+ maxDiffBytes,
333
+ severityResolution: parseSeverityResolution(quorumString('independent_finder.severity_resolution', raw?.severity_resolution ?? 'highest-wins')),
334
+ };
335
+ }
336
+ function parseAsymmetricRoles(raw) {
337
+ return {
338
+ // This one's wrong-shape direction is benign -- a truthy string keeps the
339
+ // rule enforced -- but an operator who writes `no` still got the opposite
340
+ // of what they wrote, and was not told.
341
+ finderNeCritic: quorumBoolean('asymmetric_roles.finder_ne_critic', raw?.finder_ne_critic ?? true),
342
+ // Copied, not aliased — same reason as parsePlanQuorum() above.
343
+ pairs: quorumPairs('asymmetric_roles.pairs', raw?.pairs ?? DEFAULT_ASYMMETRIC_PAIRS.map((p) => ({ ...p }))),
344
+ };
345
+ }
346
+ export function parseCrosscheckPolicy(yamlText, options = {}) {
347
+ const doc = (parseYaml(yamlText) ?? {});
348
+ if (!doc.providers || Object.keys(doc.providers).length === 0) {
349
+ throw new CrosscheckError('crosscheck.invalid-policy', 'crosscheck.yml has no providers.');
350
+ }
351
+ const providers = {};
352
+ for (const [name, raw] of Object.entries(doc.providers)) {
353
+ const config = parseProvider(name, raw ?? {});
354
+ providers[name] =
355
+ options.offline && config.kind !== 'native'
356
+ ? // The switch is the decider now, and says so: a reader that saw
357
+ // `auto` here would report a box that lacks the binary, which is a
358
+ // different fault with a different fix.
359
+ { ...config, enabled: false, enabledSource: 'offline' }
360
+ : config;
361
+ }
362
+ return {
363
+ providers,
364
+ quorumRule: {
365
+ agreement: doc.quorum_rule?.agreement ?? '2-of-3',
366
+ minProviders: quorumNumber('quorum_rule.min_providers', doc.quorum_rule?.min_providers ?? 2),
367
+ acceptNonGatingActives: quorumBoolean('quorum_rule.accept_non_gating_actives', doc.quorum_rule?.accept_non_gating_actives ?? false),
368
+ },
369
+ planQuorum: parsePlanQuorum(doc.plan_quorum),
370
+ asymmetricRoles: parseAsymmetricRoles(doc.asymmetric_roles),
371
+ roleIsolation: parseRoleIsolation(doc.role_isolation),
372
+ // Not touched by `offline`. That switch exists to stop this process
373
+ // reaching out of the machine, and it already does: it disables every
374
+ // non-native provider, and runIndependentFinder() invokes providers
375
+ // through the same enabled check runQuorumCase() does. Flipping
376
+ // `independent_finder.enabled` here as well would conflate "make no calls"
377
+ // with "make no reconciliations", and `crossfind reconcile` — which is
378
+ // pure, offline, and calls nothing — would stop answering under it.
379
+ independentFinder: parseIndependentFinder(doc.independent_finder),
380
+ };
381
+ }
382
+ /**
383
+ * Reads the policy, honouring `SMITH_CROSSCHECK_OFFLINE`: set it to anything
384
+ * non-empty and no external provider can be invoked, whatever the file says.
385
+ *
386
+ * The switch is read here rather than passed down from each call site because
387
+ * the property it protects is process-wide — "this process makes no judge
388
+ * calls out of the machine" — and because it has to survive `spawn`, which
389
+ * inherits the environment but not an argument. The CLI tests exercise the
390
+ * built binary as a subprocess; nothing else would reach them.
391
+ */
392
+ export function loadCrosscheckPolicy(filePath = CROSSCHECK_POLICY_PATH, options = {}) {
393
+ return parseCrosscheckPolicy(readFileSync(filePath, 'utf8'), {
394
+ offline: options.offline ?? Boolean(process.env.SMITH_CROSSCHECK_OFFLINE),
395
+ });
396
+ }