@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,421 @@
1
+ import { loadBudgetPolicy } from './budgets.js';
2
+ import { loadCrosscheckPolicy, } from './crosscheck.js';
3
+ import { appendEvent } from './events.js';
4
+ import { livePlanTasks, loadPlan, planRefTaskId, } from './plan.js';
5
+ import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
6
+ // Defensive field readers: TaskSpecRecord is Record<string, unknown>-ish
7
+ // (schema-validated elsewhere, not here), so every field read below narrows
8
+ // via typeof/Array.isArray before use — same precedent as cli.ts's
9
+ // `(t.claims as string[] | undefined) ?? []`, minus the unchecked cast.
10
+ function taskTokens(t) {
11
+ const budget = t.budget;
12
+ if (typeof budget !== 'object' || budget === null)
13
+ return 0;
14
+ const tokens = budget.tokens;
15
+ return typeof tokens === 'number' ? tokens : 0;
16
+ }
17
+ function taskCase(t) {
18
+ return typeof t.case === 'string' ? t.case : undefined;
19
+ }
20
+ function taskAgentRole(t) {
21
+ return typeof t.agent_role === 'string' ? t.agent_role : undefined;
22
+ }
23
+ function taskNonfunctionalClauses(t) {
24
+ const contract = t.contract;
25
+ if (typeof contract !== 'object' || contract === null)
26
+ return [];
27
+ const clauses = contract.nonfunctional_clauses;
28
+ return Array.isArray(clauses) ? clauses.filter((c) => typeof c === 'string') : [];
29
+ }
30
+ function taskConfidence(t) {
31
+ return typeof t.confidence === 'number' ? t.confidence : undefined;
32
+ }
33
+ /**
34
+ * Trigger 2 of three, on its own: which of the plan's live tasks are
35
+ * security-sensitive, one entry per (task, match) pair — a task can fire on
36
+ * its `case`, its `agent_role`, AND each matching `nonfunctional_clauses`
37
+ * keyword hit, each reported separately so the operator sees exactly what
38
+ * tripped it.
39
+ *
40
+ * Split out of `evaluatePlanQuorumTriggers` (which still calls it, unchanged)
41
+ * because effort.ts asks the same question for a different reason: an epic
42
+ * whose plan is security-sensitive may not run at a cheap effort tier, and
43
+ * that floor has to be computed from the same list the quorum uses or the two
44
+ * answers drift. Reusing the evaluation is the point; a second copy of the
45
+ * keyword-matching rules would be a second policy.
46
+ */
47
+ export function evaluatePlanSecurityTriggers(plan, policy) {
48
+ const triggers = [];
49
+ const securityCases = new Set(policy.securityCases);
50
+ const securityRoles = new Set(policy.securityRoles);
51
+ for (const t of livePlanTasks(plan)) {
52
+ const taskId = t.task_id;
53
+ const caseValue = taskCase(t);
54
+ if (caseValue !== undefined && securityCases.has(caseValue)) {
55
+ triggers.push({ kind: 'security', taskId, matchType: 'case', matchedValue: caseValue });
56
+ }
57
+ const role = taskAgentRole(t);
58
+ if (role !== undefined && securityRoles.has(role)) {
59
+ triggers.push({ kind: 'security', taskId, matchType: 'agent_role', matchedValue: role });
60
+ }
61
+ for (const clause of taskNonfunctionalClauses(t)) {
62
+ const lowerClause = clause.toLowerCase();
63
+ const matchedKeyword = policy.securityKeywords.find((kw) => lowerClause.includes(kw.toLowerCase()));
64
+ if (matchedKeyword !== undefined) {
65
+ triggers.push({
66
+ kind: 'security',
67
+ taskId,
68
+ matchType: 'nonfunctional_clause',
69
+ matchedValue: clause,
70
+ matchedKeyword,
71
+ });
72
+ }
73
+ }
74
+ }
75
+ return triggers;
76
+ }
77
+ /**
78
+ * Pure, no I/O: given a loaded plan, the plan_quorum policy, and the epic's
79
+ * token cap, decide which of the three crosscheck.yml plan_quorum triggers
80
+ * fire, each carrying the evidence an operator (or a judge prompt) needs to
81
+ * evaluate it without re-deriving it from the plan.
82
+ *
83
+ * All three triggers read `livePlanTasks(plan)`, never `plan.tasks`. D-113,
84
+ * carried as D-185: a plan version keeps each superseded copy of a task beside
85
+ * the record that replaced it, under the same `task_id` (D-121), so the raw
86
+ * field is a history and not the plan's ask. plan-v5.json on disk holds 13
87
+ * records for 5 live tasks, and read raw it declares 1,110,000 tokens against
88
+ * a real 410,000 and names four tasks security-sensitive three times each.
89
+ * Both numbers are evidence a judge is asked to weigh, so both have to be the
90
+ * plan's, not the amendment history's. Same rule, same reason, as D-126.
91
+ */
92
+ export function evaluatePlanQuorumTriggers(plan, policy, epicCapTokens, opts = {}) {
93
+ const triggers = [];
94
+ const tasks = livePlanTasks(plan);
95
+ // Trigger 1 — epic budget >= budget_ratio * epicCapTokens.
96
+ const totalTokens = tasks.reduce((sum, t) => sum + taskTokens(t), 0);
97
+ if (totalTokens >= policy.budgetRatio * epicCapTokens) {
98
+ triggers.push({
99
+ kind: 'budget',
100
+ totalTokens,
101
+ capTokens: epicCapTokens,
102
+ ratio: policy.budgetRatio,
103
+ });
104
+ }
105
+ // Trigger 2 — security-sensitive (extracted below: effort.ts asks the same
106
+ // question on its own, to decide whether an epic may run at a cheap tier).
107
+ triggers.push(...evaluatePlanSecurityTriggers(plan, policy));
108
+ // Trigger 3 — low confidence: per task (task-spec.schema.json's
109
+ // `confidence`, set only for origin: inferred tasks) and/or the planner's
110
+ // own self-report for the plan as a whole.
111
+ for (const t of tasks) {
112
+ const confidence = taskConfidence(t);
113
+ if (confidence !== undefined && confidence < policy.confidenceThreshold) {
114
+ triggers.push({
115
+ kind: 'low-confidence',
116
+ source: 'task',
117
+ taskId: t.task_id,
118
+ value: confidence,
119
+ threshold: policy.confidenceThreshold,
120
+ });
121
+ }
122
+ }
123
+ if (opts.plannerConfidence !== undefined && opts.plannerConfidence < policy.confidenceThreshold) {
124
+ triggers.push({
125
+ kind: 'low-confidence',
126
+ source: 'planner',
127
+ value: opts.plannerConfidence,
128
+ threshold: policy.confidenceThreshold,
129
+ });
130
+ }
131
+ return triggers;
132
+ }
133
+ /**
134
+ * Pseudo task id for a plan-level (not task-level) event/judge-request —
135
+ * distinct from any real task_id and from epic.ts's
136
+ * `${epicId}/${RESERVED_TASK_ID}`. event.schema.json's `task_id` is an
137
+ * unconstrained free-form string, so this is schema-safe; the shape is defined
138
+ * in plan.ts (not here) because db/projector.ts's foldTasks() must recognise it
139
+ * to keep a plan ref out of the tasks projection, and db/ must not import a
140
+ * quorum host.
141
+ */
142
+ function planPseudoTaskId(plan) {
143
+ return planRefTaskId(plan.epic_id, plan.version);
144
+ }
145
+ function describeTrigger(t) {
146
+ if (t.kind === 'budget') {
147
+ return `budget: ${t.totalTokens} tokens >= ${t.ratio} * ${t.capTokens} cap`;
148
+ }
149
+ if (t.kind === 'security') {
150
+ const keywordSuffix = t.matchedKeyword ? ` (keyword "${t.matchedKeyword}")` : '';
151
+ return `security: task ${t.taskId} matched ${t.matchType} = "${t.matchedValue}"${keywordSuffix}`;
152
+ }
153
+ const taskSuffix = t.taskId ? ` task ${t.taskId}` : '';
154
+ return `low-confidence: ${t.source}${taskSuffix} confidence ${t.value} < ${t.threshold}`;
155
+ }
156
+ /**
157
+ * The reason this plan qualified for a quorum, for the event payload.
158
+ *
159
+ * The first trigger that fired, and `fired_triggers` lists the whole set in
160
+ * the same order — so `trigger_reason` and `fired_triggers[0]` always name the
161
+ * same trigger, and a reader can check that relation without a precedence
162
+ * table. Null when nothing fired: a mechanical "nothing to critique" has no
163
+ * reason to name, and this payload already spells honest-empty as null for
164
+ * `decision` and `escalation_reason`.
165
+ *
166
+ * This was the constant `'low-confidence-plan'` on every path. The two plan
167
+ * quorums on dogfood-mcp-1 each fired four security triggers and zero
168
+ * confidence triggers — that run passed no confidence value at all — and both
169
+ * events name low confidence anyway, with the contradicting `fired_triggers`
170
+ * list sitting in the same payload (D-112).
171
+ */
172
+ function planTriggerReason(triggers) {
173
+ const first = triggers[0];
174
+ if (!first)
175
+ return null;
176
+ if (first.kind === 'budget')
177
+ return 'budget-plan';
178
+ if (first.kind === 'security')
179
+ return 'security-plan';
180
+ return 'low-confidence-plan';
181
+ }
182
+ /** A judge call is a network round-trip, not a test — same rationale and same numbers as gate.ts's/epic.ts's DEFAULT_JUDGE_BUDGET; not imported from either since planQuorum.ts must not depend on gate.ts/epic.ts (parallel quorum_triggers hosts, not a hierarchy). */
183
+ const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
184
+ /**
185
+ * Pure prompt builder, mirrors quorum.ts's findingJudgeRequest() and
186
+ * epic.ts's epicVerdictJudgeRequest() style and trust boundary: the prompt
187
+ * carries the CLAIM ONLY (epic id, plan version, task ids/objectives/
188
+ * case/token budgets, edge count, fired triggers with their evidence) —
189
+ * never file contents, never a diff. The tasks it lists are `livePlanTasks`,
190
+ * not `plan.tasks`: this prompt is the whole of what the critic sees, so a
191
+ * superseded record listed here is an objective the plan withdrew, offered to
192
+ * a judge as the plan's current ask (D-185). Carries
193
+ * asymmetric_roles.critic_mandate ("refute, not confirm"): the judge's
194
+ * mandate is to REFUTE the plan's soundness; it critiques the plan, it does
195
+ * not authorize any change to it.
196
+ */
197
+ export function planQuorumJudgeRequest(plan, triggers, budget) {
198
+ const tasks = livePlanTasks(plan);
199
+ const taskLines = tasks.length > 0
200
+ ? tasks
201
+ .map((t) => {
202
+ const caseValue = taskCase(t) ?? '(no case)';
203
+ const objective = typeof t.objective === 'string' ? t.objective : '(no objective)';
204
+ return ` ${t.task_id} [${caseValue}, ${taskTokens(t)} tokens]: ${objective}`;
205
+ })
206
+ .join('\n')
207
+ : ' (no tasks)';
208
+ const triggerLines = triggers.length > 0 ? triggers.map((t) => ` - ${describeTrigger(t)}`).join('\n') : ' (none)';
209
+ const prompt = [
210
+ 'You are an adversarial critic in an automated plan-quorum gate.',
211
+ `Plan "${plan.epic_id}" v${plan.version} is claimed sound as scoped.`,
212
+ 'Your mandate is to REFUTE that claim: assume it is NOT sound until the',
213
+ 'evidence below forces you to agree. You are critiquing the plan, not',
214
+ 'authorizing any change to it — nothing you say rewrites the plan.',
215
+ '',
216
+ `Epic: ${plan.epic_id}`,
217
+ `Plan version: ${plan.version}`,
218
+ `Tasks: ${tasks.length}`,
219
+ taskLines,
220
+ '',
221
+ `Dependency edges: ${plan.edges.length}`,
222
+ '',
223
+ `Fired triggers (${triggers.length}):`,
224
+ triggerLines,
225
+ '',
226
+ 'You are judging the claim as stated above; you do not have file contents',
227
+ 'or a diff for any task in this plan.',
228
+ '',
229
+ 'Return only JSON matching judge-verdict.schema.json:',
230
+ '{"verdict": "confirm" | "refute", "rationale": "<one or two sentences>"}',
231
+ '"confirm" = the plan is genuinely sound as scoped. "refute" = it is not.',
232
+ ].join('\n');
233
+ return {
234
+ kind: 'plan-critique',
235
+ taskId: planPseudoTaskId(plan),
236
+ inputRefs: { epic_id: plan.epic_id, plan_version: String(plan.version) },
237
+ prompt,
238
+ schemaName: 'judge-verdict',
239
+ budget,
240
+ };
241
+ }
242
+ /**
243
+ * One key set for all three paths (P9-23). The short-circuit paths run no
244
+ * quorum, so their gating fields are honestly empty — `outcome: 'not-run'`
245
+ * rather than a missing key, because a consumer that has to distinguish
246
+ * "absent" from "empty" ends up guessing, and the whole point of the record
247
+ * is that nobody has to guess whether a check happened.
248
+ */
249
+ function planQuorumDecisionPayload(args) {
250
+ const { quorum, plan, finderProvider, triggers, sound, endorsedBy } = args;
251
+ const gating = quorum?.gating;
252
+ return {
253
+ task_id: planPseudoTaskId(plan),
254
+ epic_id: plan.epic_id,
255
+ plan_version: plan.version,
256
+ finding_id: null,
257
+ trigger_reason: planTriggerReason(triggers),
258
+ finder_provider: finderProvider,
259
+ /** `not-run` is a first-class outcome here: no quorum was called at all. */
260
+ outcome: gating?.outcome ?? 'not-run',
261
+ decision: gating?.outcome === 'decided' ? gating.decision : null,
262
+ agreement: gating?.outcome === 'decided' ? gating.agreement : null,
263
+ gating_participants: gating?.outcome === 'decided' ? gating.participants : [],
264
+ escalation_reason: gating?.outcome === 'escalate' ? gating.reason : null,
265
+ rationales: gating?.outcome === 'escalate' ? gating.rationales : [],
266
+ participants: (quorum?.participants ?? []).map((p) => ({
267
+ provider: p.provider,
268
+ mode: p.mode,
269
+ ok: p.ok,
270
+ verdict: p.verdict,
271
+ excluded_as_finder: p.excludedAsFinder,
272
+ })),
273
+ native_verdict: 'confirm',
274
+ fired_triggers: triggers.map(describeTrigger),
275
+ /** Whether the plan is endorsed as sound after the quorum spoke — never "approved", this is critique-only. */
276
+ sound,
277
+ /** Non-null exactly when `sound` is true; null pairs with a critique or an escalation. */
278
+ endorsed_by: endorsedBy,
279
+ };
280
+ }
281
+ /** Emit the one quorum-decision this run is allowed (step 1, 2 or 5 — never two). */
282
+ async function emitPlanQuorumDecision(args, ctx, opts) {
283
+ await appendEvent({
284
+ session_id: ctx.sessionId,
285
+ actor: ctx.actor ?? 'system',
286
+ event_type: 'quorum-decision',
287
+ task_id: planPseudoTaskId(args.plan),
288
+ plan_version: ctx.planVersion,
289
+ causal_parent: ctx.causalParent,
290
+ payload: planQuorumDecisionPayload(args),
291
+ }, opts);
292
+ }
293
+ /**
294
+ * Operator-invoked critique for one plan version: mechanical trigger
295
+ * evaluation first, then (only when triggered and it isn't free to skip) a
296
+ * cross-provider quorum on the claim "this plan is sound as scoped". Never
297
+ * mutates the plan file and never blocks anything itself — the caller (the
298
+ * CLI) surfaces the outcome for a human; a non-`endorsed` exit means the
299
+ * operator must look before approving the plan.
300
+ */
301
+ export async function runPlanQuorum(input, ctx, opts = {}) {
302
+ const plan = loadPlan(input.epicId, input.version, input.planOpts);
303
+ const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
304
+ const epicCapTokens = input.epicCapTokens ?? loadBudgetPolicy().epic.capTokens;
305
+ const triggers = evaluatePlanQuorumTriggers(plan, policy.planQuorum, epicCapTokens, {
306
+ plannerConfidence: input.plannerConfidence,
307
+ });
308
+ const nativeProvider = nativeProviderName(policy);
309
+ // Step 1 — mechanical_oracles_first: zero fired triggers is a mechanical
310
+ // "nothing to critique". Zero judge calls — but not zero events (P9-23):
311
+ // an endorsement nobody can find in the log is indistinguishable from a
312
+ // check that never ran.
313
+ if (triggers.length === 0) {
314
+ await emitPlanQuorumDecision({ plan, finderProvider: nativeProvider, triggers, sound: true, endorsedBy: 'no-triggers' }, ctx, opts);
315
+ return {
316
+ outcome: 'endorsed',
317
+ epicId: input.epicId,
318
+ version: input.version,
319
+ triggers,
320
+ endorsedBy: 'no-triggers',
321
+ };
322
+ }
323
+ const providers = enabledExternalProviders(policy);
324
+ // Step 2 — zero-cost-by-default short-circuit, see module header §3. The
325
+ // skip stays free (zero judge calls); the silence does not. This is the
326
+ // only path the shipped crosscheck.yml can take, so it is precisely the
327
+ // path whose record an operator will need six weeks later.
328
+ if (providers.length === 0) {
329
+ await emitPlanQuorumDecision({
330
+ plan,
331
+ finderProvider: nativeProvider,
332
+ triggers,
333
+ sound: true,
334
+ endorsedBy: 'default-no-provider',
335
+ }, ctx, opts);
336
+ return {
337
+ outcome: 'endorsed',
338
+ epicId: input.epicId,
339
+ version: input.version,
340
+ triggers,
341
+ endorsedBy: 'default-no-provider',
342
+ };
343
+ }
344
+ const budget = input.crosscheck?.budget ?? DEFAULT_JUDGE_BUDGET;
345
+ const rationale = `${plan.epic_id} v${plan.version}: ${triggers.length} trigger(s) fired; native planner claims the plan is sound as scoped.`;
346
+ // Step 3 — the claim is native's own (the planner's); finder_ne_critic
347
+ // excludes it from gating (§3). Native's own report of the ACTUAL fired
348
+ // triggers lives in the event payload (fired_triggers), not in this
349
+ // "confirm" claim — the claim is always "sound", exactly as epic.ts's
350
+ // native claim is always "ready".
351
+ const quorum = await runQuorumCase({
352
+ taskId: planPseudoTaskId(plan),
353
+ triggerReason: 'low-confidence-plan',
354
+ finderProvider: nativeProvider,
355
+ kind: 'plan-critique',
356
+ native: { provider: nativeProvider, verdict: 'confirm', rationale },
357
+ providers,
358
+ request: planQuorumJudgeRequest(plan, triggers, budget),
359
+ policy,
360
+ quorumPolicy: { minProviders: policy.quorumRule.minProviders },
361
+ fetchImpl: input.crosscheck?.fetchImpl,
362
+ }, ctx, opts);
363
+ // Step 4 — map the quorum's gating decision to endorsed/critiqued/
364
+ // escalated. escalated only holds when an ACTIVE judge actually ran
365
+ // (epic.ts's runEpicVerdict rule, reused verbatim): shadow-only/all-failed
366
+ // participants have zero gating power by construction, so there's nothing
367
+ // for an operator to arbitrate and the plan is endorsed exactly as it
368
+ // would have been with 0 externals (shadow verdicts still surfaced via
369
+ // quorum.participants for calibration, per on_disagreement's "surface both
370
+ // rationales" applying equally to the active-disagreement case below).
371
+ const gating = quorum.gating;
372
+ let outcome;
373
+ if (gating.outcome === 'decided') {
374
+ outcome =
375
+ gating.decision === 'confirm'
376
+ ? {
377
+ outcome: 'endorsed',
378
+ epicId: input.epicId,
379
+ version: input.version,
380
+ triggers,
381
+ endorsedBy: 'quorum',
382
+ quorum,
383
+ }
384
+ : { outcome: 'critiqued', epicId: input.epicId, version: input.version, triggers, quorum };
385
+ }
386
+ else {
387
+ const hadActiveJudge = quorum.participants.some((p) => p.mode === 'active' && p.ok && !p.excludedAsFinder);
388
+ outcome = hadActiveJudge
389
+ ? {
390
+ outcome: 'escalated',
391
+ epicId: input.epicId,
392
+ version: input.version,
393
+ triggers,
394
+ reason: gating.reason,
395
+ quorum,
396
+ }
397
+ : {
398
+ outcome: 'endorsed',
399
+ epicId: input.epicId,
400
+ version: input.version,
401
+ triggers,
402
+ // Shadow-only/all-failed judges have zero gating power, so the
403
+ // endorsement is still the shipped default's, not a vote's — the
404
+ // participants list on the payload shows who ran anyway.
405
+ endorsedBy: 'default-no-provider',
406
+ quorum,
407
+ };
408
+ }
409
+ // Step 5 — emit exactly once, for any case that actually ran a quorum.
410
+ // Never a plan-mutating event: quorum-decision is not a type any plan-cut
411
+ // path reads or reacts to (critique-only invariant, see module header).
412
+ await emitPlanQuorumDecision({
413
+ quorum,
414
+ plan,
415
+ finderProvider: nativeProvider,
416
+ triggers,
417
+ sound: outcome.outcome === 'endorsed',
418
+ endorsedBy: outcome.outcome === 'endorsed' ? outcome.endorsedBy : null,
419
+ }, ctx, opts);
420
+ return outcome;
421
+ }