@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.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- 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
|
+
}
|