@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,275 @@
|
|
|
1
|
+
import { eventTaskId } from './events.js';
|
|
2
|
+
import { taskIdsMatch } from './taskId.js';
|
|
3
|
+
/**
|
|
4
|
+
* Critic work that reaches the event log without a `dispatch_decision`.
|
|
5
|
+
*
|
|
6
|
+
* `key` is the payload field naming the role that did the work, and `label`
|
|
7
|
+
* is how the operator hears it in a detail line. Adding an entry widens the
|
|
8
|
+
* audit's domain; leaving one off is how D-124 happened, so a new event type
|
|
9
|
+
* that records a critic's output belongs here at the moment it is written.
|
|
10
|
+
*/
|
|
11
|
+
const CRITIC_WORK_EVENTS = {
|
|
12
|
+
'spec-review-recorded': { key: 'reviewed_by', label: 'spec review' },
|
|
13
|
+
// `smith epic goal-check` reaches the log the same way `epic spec-review`
|
|
14
|
+
// does — a judge's verdict on the planner's output, written by a command
|
|
15
|
+
// rather than by a dispatch — so it is in the domain for the same reason,
|
|
16
|
+
// and listed here at the moment it was written rather than after a D-124.
|
|
17
|
+
'goal-check-recorded': { key: 'checked_by', label: 'spec-vs-goal check' },
|
|
18
|
+
};
|
|
19
|
+
function payloadString(payload, key) {
|
|
20
|
+
const value = payload[key];
|
|
21
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Whether two task scopes can answer for each other.
|
|
25
|
+
*
|
|
26
|
+
* They disagree only when both are named and differ. A dispatch with no task
|
|
27
|
+
* id answers for anything: pre-task-id history is on disk and unrewritable,
|
|
28
|
+
* and refusing it would turn every one of those runs into a wall of false
|
|
29
|
+
* alarms rather than a finding anyone can act on.
|
|
30
|
+
*
|
|
31
|
+
* D-181: "differ" is taskIdsMatch's question, not `===`'s. The log writes one
|
|
32
|
+
* task both qualified and bare, so a raw comparison here un-pairs a critic
|
|
33
|
+
* from the very finder it ran against whenever the two dispatches were
|
|
34
|
+
* spelled differently -- and the report says `unverifiable` about a pair it
|
|
35
|
+
* holds both halves of. taskIdsMatch keeps this function's own guarantee: two
|
|
36
|
+
* DIFFERENT qualified ids stay different tasks even when their bare halves
|
|
37
|
+
* collide, which is the whole point of scoping the finder search.
|
|
38
|
+
*/
|
|
39
|
+
function scopesAgree(a, b) {
|
|
40
|
+
return a === null || b === null || taskIdsMatch(a, b);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Flatten a session's events down to its dispatch records, in log order.
|
|
44
|
+
*
|
|
45
|
+
* Pre-P9-23 events are kept with `model: null` rather than filtered out.
|
|
46
|
+
* They are on disk and are never rewritten (the log is append-only), and
|
|
47
|
+
* dropping them would turn "this dispatch cannot be checked" into "no such
|
|
48
|
+
* dispatch happened" — the report would look emptier and cleaner than the
|
|
49
|
+
* history it describes.
|
|
50
|
+
*/
|
|
51
|
+
export function readDispatchRecords(events) {
|
|
52
|
+
const records = [];
|
|
53
|
+
for (const stored of events) {
|
|
54
|
+
const record = stored.record;
|
|
55
|
+
if (record.event_type !== 'dispatch_decision')
|
|
56
|
+
continue;
|
|
57
|
+
const payload = (record.payload ?? {});
|
|
58
|
+
const role = payloadString(payload, 'agent_role');
|
|
59
|
+
if (!role)
|
|
60
|
+
continue;
|
|
61
|
+
records.push({
|
|
62
|
+
eventId: stored.event_id,
|
|
63
|
+
ts: record.ts ?? '',
|
|
64
|
+
taskId: eventTaskId(record),
|
|
65
|
+
role,
|
|
66
|
+
provider: payloadString(payload, 'provider') ?? '',
|
|
67
|
+
model: payloadString(payload, 'model'),
|
|
68
|
+
modelTier: payloadString(payload, 'model_tier'),
|
|
69
|
+
agentId: typeof record.agent_id === 'string' && record.agent_id ? record.agent_id : null,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return records;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Flatten a session's events down to its critic-work records, in log order.
|
|
76
|
+
*
|
|
77
|
+
* The mirror of readDispatchRecords for the other route into the log. A
|
|
78
|
+
* record here is a claim that a critic role produced something; it carries no
|
|
79
|
+
* model, because no dispatch chose one, which is exactly why an uncovered
|
|
80
|
+
* record can only ever be `unverifiable`.
|
|
81
|
+
*/
|
|
82
|
+
export function readCriticWorkRecords(events) {
|
|
83
|
+
const records = [];
|
|
84
|
+
for (const stored of events) {
|
|
85
|
+
const record = stored.record;
|
|
86
|
+
const spec = CRITIC_WORK_EVENTS[record.event_type];
|
|
87
|
+
if (!spec)
|
|
88
|
+
continue;
|
|
89
|
+
const payload = (record.payload ?? {});
|
|
90
|
+
const role = payloadString(payload, spec.key);
|
|
91
|
+
if (!role)
|
|
92
|
+
continue;
|
|
93
|
+
records.push({
|
|
94
|
+
eventId: stored.event_id,
|
|
95
|
+
ts: record.ts ?? '',
|
|
96
|
+
taskId: eventTaskId(record),
|
|
97
|
+
role,
|
|
98
|
+
label: spec.label,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return records;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The critic-work records no dispatch of that role can answer for.
|
|
105
|
+
*
|
|
106
|
+
* A record is covered when a dispatch of the same role falls in the window
|
|
107
|
+
* (previous record of that role, this record] — since the last time this
|
|
108
|
+
* critic recorded something, and no later than the record itself.
|
|
109
|
+
*
|
|
110
|
+
* The window is the whole check. Treating dispatches as fungible tokens —
|
|
111
|
+
* five spec-reviewer dispatches, so the next five recorded reviews are fine —
|
|
112
|
+
* is exactly the laundering `precedingFinder` refuses on the finder side, and
|
|
113
|
+
* it is not hypothetical: dogfood-mcp-1 dispatched five spec-reviewers across
|
|
114
|
+
* two days and then recorded two more reviews by hand three days later, which
|
|
115
|
+
* a fungible count would have passed. A dispatch that predates the previous
|
|
116
|
+
* record already answered for that one; it cannot answer twice.
|
|
117
|
+
*/
|
|
118
|
+
function uncoveredCriticWork(work, dispatches) {
|
|
119
|
+
const uncovered = [];
|
|
120
|
+
let since = '';
|
|
121
|
+
for (const record of [...work].sort((a, b) => a.ts.localeCompare(b.ts))) {
|
|
122
|
+
if (!dispatches.some((d) => d.ts > since && d.ts <= record.ts))
|
|
123
|
+
uncovered.push(record);
|
|
124
|
+
since = record.ts;
|
|
125
|
+
}
|
|
126
|
+
return uncovered;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The finder dispatch a given critic dispatch was reviewing: the latest one
|
|
130
|
+
* at or before the critic's timestamp, on the critic's own task.
|
|
131
|
+
*
|
|
132
|
+
* "At or before" is the whole point. A later re-plan on a different model
|
|
133
|
+
* cannot retroactively make an earlier review adversarial, and comparing a
|
|
134
|
+
* critic against the newest finder dispatch in the session would let exactly
|
|
135
|
+
* that launder a violation into a pass.
|
|
136
|
+
*
|
|
137
|
+
* The task axis is the same argument on the other coordinate, and it is not
|
|
138
|
+
* hypothetical: on dogfood-mcp-1 four `integration` verifiers were answered
|
|
139
|
+
* for by a task-4 reviewer dispatched three days earlier, on a task with no
|
|
140
|
+
* reviewer dispatch of its own anywhere in the log. Different code, different
|
|
141
|
+
* finding, reported ok. Evidence about one task vouches for nothing about
|
|
142
|
+
* another (D-172).
|
|
143
|
+
*/
|
|
144
|
+
function precedingFinder(finders, critic) {
|
|
145
|
+
let best = null;
|
|
146
|
+
for (const finder of finders) {
|
|
147
|
+
if (finder.ts > critic.ts)
|
|
148
|
+
continue;
|
|
149
|
+
if (!scopesAgree(finder.taskId, critic.taskId))
|
|
150
|
+
continue;
|
|
151
|
+
if (!best || finder.ts >= best.ts)
|
|
152
|
+
best = finder;
|
|
153
|
+
}
|
|
154
|
+
return best;
|
|
155
|
+
}
|
|
156
|
+
function checkOneCritic(pair, critic, finders) {
|
|
157
|
+
const finder = precedingFinder(finders, critic);
|
|
158
|
+
const base = {
|
|
159
|
+
finder: pair.finder,
|
|
160
|
+
critic: pair.critic,
|
|
161
|
+
criticEventId: critic.eventId,
|
|
162
|
+
criticModel: critic.model,
|
|
163
|
+
finderEventId: finder?.eventId ?? null,
|
|
164
|
+
finderModel: finder?.model ?? null,
|
|
165
|
+
};
|
|
166
|
+
if (!finder) {
|
|
167
|
+
return {
|
|
168
|
+
...base,
|
|
169
|
+
status: 'unverifiable',
|
|
170
|
+
detail: `${pair.critic} ran at ${critic.ts}${critic.taskId ? ` on ${critic.taskId}` : ''} with no preceding ${pair.finder} dispatch to compare against.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (!critic.model || !finder.model) {
|
|
174
|
+
const missing = !critic.model ? pair.critic : pair.finder;
|
|
175
|
+
return {
|
|
176
|
+
...base,
|
|
177
|
+
status: 'unverifiable',
|
|
178
|
+
detail: `The ${missing} dispatch records no model, so finder_ne_critic cannot be evaluated.`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (critic.model === finder.model) {
|
|
182
|
+
return {
|
|
183
|
+
...base,
|
|
184
|
+
status: 'violation',
|
|
185
|
+
detail: `${pair.critic} ran on ${critic.model}, the same model as the ${pair.finder} dispatch it followed.`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
...base,
|
|
190
|
+
status: 'ok',
|
|
191
|
+
detail: `${pair.critic} ran on ${critic.model}; ${pair.finder} ran on ${finder.model}.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Assert crosscheck.yml's finder_ne_critic against what the log actually
|
|
196
|
+
* records. Every critic dispatch is checked, not just the last one: a session
|
|
197
|
+
* that got it wrong once and right afterwards did get it wrong once.
|
|
198
|
+
*/
|
|
199
|
+
export function checkDispatchAsymmetry(events, pairs, options) {
|
|
200
|
+
const taskId = options.taskId ?? null;
|
|
201
|
+
const all = readDispatchRecords(events);
|
|
202
|
+
// D-181: `--task` is answered across both spellings of the id. The log
|
|
203
|
+
// writes one task both qualified and bare, so a raw `===` here answered the
|
|
204
|
+
// operator with the subset that happened to be spelled their way — and a
|
|
205
|
+
// dropped critic dispatch reads as a critic that never ran.
|
|
206
|
+
const inScope = (scope) => (r) => r.taskId !== null && taskIdsMatch(r.taskId, scope);
|
|
207
|
+
const records = taskId ? all.filter(inScope(taskId)) : all;
|
|
208
|
+
const allWork = readCriticWorkRecords(events);
|
|
209
|
+
const criticWork = taskId ? allWork.filter(inScope(taskId)) : allWork;
|
|
210
|
+
// A policy that declares no pairs cannot be satisfied OR violated, and
|
|
211
|
+
// reporting that as a pass would advertise an audit nobody configured.
|
|
212
|
+
if (pairs.length === 0) {
|
|
213
|
+
return {
|
|
214
|
+
sessionId: options.sessionId,
|
|
215
|
+
taskId,
|
|
216
|
+
dispatchesExamined: records.length,
|
|
217
|
+
criticWorkExamined: criticWork.length,
|
|
218
|
+
checks: [
|
|
219
|
+
{
|
|
220
|
+
finder: '*',
|
|
221
|
+
critic: '*',
|
|
222
|
+
status: 'unverifiable',
|
|
223
|
+
criticEventId: null,
|
|
224
|
+
criticModel: null,
|
|
225
|
+
finderEventId: null,
|
|
226
|
+
finderModel: null,
|
|
227
|
+
detail: 'crosscheck.yml declares no asymmetric_roles.pairs, so finder_ne_critic asserts nothing.',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
ok: false,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const checks = [];
|
|
234
|
+
for (const pair of pairs) {
|
|
235
|
+
const critics = records.filter((r) => r.role === pair.critic);
|
|
236
|
+
const work = criticWork.filter((r) => r.role === pair.critic);
|
|
237
|
+
if (critics.length === 0 && work.length === 0) {
|
|
238
|
+
checks.push({
|
|
239
|
+
finder: pair.finder,
|
|
240
|
+
critic: pair.critic,
|
|
241
|
+
status: 'not-applicable',
|
|
242
|
+
criticEventId: null,
|
|
243
|
+
criticModel: null,
|
|
244
|
+
finderEventId: null,
|
|
245
|
+
finderModel: null,
|
|
246
|
+
detail: `No ${pair.critic} dispatch in scope; the pair had nothing to check.`,
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const finders = records.filter((r) => r.role === pair.finder);
|
|
251
|
+
for (const critic of critics) {
|
|
252
|
+
checks.push(checkOneCritic(pair, critic, finders));
|
|
253
|
+
}
|
|
254
|
+
for (const record of uncoveredCriticWork(work, critics)) {
|
|
255
|
+
checks.push({
|
|
256
|
+
finder: pair.finder,
|
|
257
|
+
critic: pair.critic,
|
|
258
|
+
status: 'unverifiable',
|
|
259
|
+
criticEventId: record.eventId,
|
|
260
|
+
criticModel: null,
|
|
261
|
+
finderEventId: null,
|
|
262
|
+
finderModel: null,
|
|
263
|
+
detail: `${pair.critic} recorded a ${record.label} at ${record.ts} with no ${pair.critic} dispatch behind it, so no model is on record and finder_ne_critic cannot be evaluated.`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
sessionId: options.sessionId,
|
|
269
|
+
taskId,
|
|
270
|
+
dispatchesExamined: records.length,
|
|
271
|
+
criticWorkExamined: criticWork.length,
|
|
272
|
+
checks,
|
|
273
|
+
ok: checks.every((c) => c.status === 'ok' || c.status === 'not-applicable'),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The join between where the runbook tells an operator to put a key and where
|
|
2
|
+
// the code looks for one.
|
|
3
|
+
//
|
|
4
|
+
// `docs/runbooks/providers.md` says: set `DEEPSEEK_API_KEY` in `.env`
|
|
5
|
+
// (gitignored, per `docs/standards/guardrails.md`). `preconditions.ts` reads
|
|
6
|
+
// `process.env` and nothing else. Both were true for months and nothing joined
|
|
7
|
+
// them, so a key placed exactly where the repo asked for it resolved
|
|
8
|
+
// `enabled: auto` to off and `smith judge preflight` reported it unset. This
|
|
9
|
+
// module is that join and nothing more.
|
|
10
|
+
//
|
|
11
|
+
// Two decisions worth stating, because both are the kind that get "fixed"
|
|
12
|
+
// later by someone who did not know they were chosen:
|
|
13
|
+
//
|
|
14
|
+
// **A file never beats the runner.** CI and the daemon export secrets into the
|
|
15
|
+
// environment; `.env` is a convenience for a developer's box. A convenience
|
|
16
|
+
// that silently replaces the real thing is how a run spends a stale key and
|
|
17
|
+
// nobody can see why. So an already-set name is left exactly as it is, and is
|
|
18
|
+
// not reported as loaded.
|
|
19
|
+
//
|
|
20
|
+
// **Not `node --env-file`.** Node can do this at the flag, but the binary is
|
|
21
|
+
// reached by more than one road -- `smith`, `node dist/cli.js`, a daemon
|
|
22
|
+
// spawn, the UI server importing a module -- and a flag on one road covers one
|
|
23
|
+
// road. Doing it in-process at the entry covers all of them.
|
|
24
|
+
//
|
|
25
|
+
// Nothing here ever returns, throws, or logs a value: the return is the list
|
|
26
|
+
// of names, which is the same discipline `smith judge preflight` already
|
|
27
|
+
// keeps.
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
/** `KEY=value`, with an optional `export` and space either side of the `=`. */
|
|
30
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
31
|
+
/** Strip one layer of matching quotes, and only a matching pair. */
|
|
32
|
+
function unquote(raw) {
|
|
33
|
+
const value = raw.trim();
|
|
34
|
+
const first = value.slice(0, 1);
|
|
35
|
+
const quoted = value.length >= 2 && (first === '"' || first === "'") && value.endsWith(first);
|
|
36
|
+
// Inside quotes every character is the operator's: a `#` is part of the key,
|
|
37
|
+
// not the start of a comment, and trailing space was typed on purpose.
|
|
38
|
+
return quoted ? value.slice(1, -1) : value;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Read `file` into `env`, setting only names it does not already hold.
|
|
42
|
+
*
|
|
43
|
+
* Returns the names it set -- never the values, and never anything about the
|
|
44
|
+
* names it skipped. A missing or unreadable file is nothing to do, not an
|
|
45
|
+
* error: most boxes have no `.env` and the factory runs fine on them.
|
|
46
|
+
*/
|
|
47
|
+
export function loadDotEnv(file, env = process.env) {
|
|
48
|
+
let contents;
|
|
49
|
+
try {
|
|
50
|
+
contents = readFileSync(file, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const named = [];
|
|
56
|
+
for (const line of contents.split('\n')) {
|
|
57
|
+
if (/^\s*(?:#|$)/.test(line))
|
|
58
|
+
continue;
|
|
59
|
+
const match = ASSIGNMENT.exec(line);
|
|
60
|
+
if (match === null)
|
|
61
|
+
continue;
|
|
62
|
+
const name = match[1];
|
|
63
|
+
const raw = match[2];
|
|
64
|
+
if (name === undefined || raw === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
if (env[name] !== undefined)
|
|
67
|
+
continue;
|
|
68
|
+
env[name] = unquote(raw);
|
|
69
|
+
named.push(name);
|
|
70
|
+
}
|
|
71
|
+
return named;
|
|
72
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
import { SmithError } from './errors.js';
|
|
4
|
+
import { EFFORT_POLICY_PATH } from './paths.js';
|
|
5
|
+
import { EFFORT_TIERS, isEffortTier } from './plan.js';
|
|
6
|
+
import { evaluatePlanSecurityTriggers } from './planQuorum.js';
|
|
7
|
+
/**
|
|
8
|
+
* How much judgment an epic buys — factory/policies/effort.yml's host.
|
|
9
|
+
*
|
|
10
|
+
* An internal tool and a payments rewrite were running the same sixteen-step
|
|
11
|
+
* loop, because the loop was the only one there was. Making a small epic cheap
|
|
12
|
+
* meant skipping steps by hand and remembering which ones, which is the
|
|
13
|
+
* "recall, do not ask" failure sensitive-paths.yml and crosscheck.yml were
|
|
14
|
+
* each written to end. This module ends it for step count: the tiers are
|
|
15
|
+
* declared in the policy file, the plan file names one, and
|
|
16
|
+
* `smith effort show` answers so the `/bs` playbooks never have to
|
|
17
|
+
* decide twice.
|
|
18
|
+
*
|
|
19
|
+
* Two invariants make the feature safe to ship:
|
|
20
|
+
*
|
|
21
|
+
* 1. **`huge` is today's flow, unchanged.** Nothing about black-smith's own
|
|
22
|
+
* behaviour moves until an epic is assigned a lower tier — effort.test.ts
|
|
23
|
+
* pins every `huge` knob to what the `/bs` playbooks already write, so a
|
|
24
|
+
* future tune of `huge` has to be a deliberate edit to a red test rather
|
|
25
|
+
* than a quiet regression for every epic that never asked for a tier.
|
|
26
|
+
* 2. **A tier scales judgment steps only.** Never a mechanical oracle, never a
|
|
27
|
+
* guardrail: gates, coverage, claim disjointness, worktree isolation, the
|
|
28
|
+
* security-reviewer's own dispatch triggers, operator sign-off, and the
|
|
29
|
+
* event log run identically at every tier. The policy file's `invariants:`
|
|
30
|
+
* list says so in prose and `resolveEffort` returns it verbatim beside
|
|
31
|
+
* every profile, so a caller reading the cheap answer reads the limit of it
|
|
32
|
+
* in the same breath. A `small` tier that skipped tests would not be cheap,
|
|
33
|
+
* it would be unverified.
|
|
34
|
+
*
|
|
35
|
+
* The tier is per EPIC, not per project (the operator's call): it is chosen at
|
|
36
|
+
* `/bs plan` time and stored on the plan file, so one project runs a `small`
|
|
37
|
+
* epic and a `huge` one without re-configuring anything.
|
|
38
|
+
*/
|
|
39
|
+
export class EffortError extends SmithError {
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The tier names, ordered cheapest-first — the index is the rank the security
|
|
43
|
+
* floor compares on. Declared in plan.ts (beside the `PlanFile.effort` field
|
|
44
|
+
* that carries them, so `validatePlan` can check one without importing this
|
|
45
|
+
* module) and re-exported here, which is where callers should read it from:
|
|
46
|
+
* this file is what gives the names meaning.
|
|
47
|
+
*/
|
|
48
|
+
export { EFFORT_TIERS, isEffortTier } from './plan.js';
|
|
49
|
+
/** Cheapest = 0. Only ever compared, never persisted — the names are the contract. */
|
|
50
|
+
function tierRank(tier) {
|
|
51
|
+
return EFFORT_TIERS.indexOf(tier);
|
|
52
|
+
}
|
|
53
|
+
const PRE_CODE_RESEARCH = ['when-needed', 'never'];
|
|
54
|
+
const PRE_CODE_UIUX = ['always', 'when-ui-criterion', 'never'];
|
|
55
|
+
const SPEC_REVIEW_ROUNDS = ['until-clean', 'single-pass'];
|
|
56
|
+
const PLAN_QUORUM = ['always', 'when-triggered'];
|
|
57
|
+
const CLOSING_SPEC_REVIEW = ['always', 'when-plan-amended'];
|
|
58
|
+
// --- parsing ---------------------------------------------------------------
|
|
59
|
+
// Every reader below rejects rather than defaults. A policy file that half
|
|
60
|
+
// parses is worse than one that fails: the missing half becomes an unstated
|
|
61
|
+
// tier assignment, which is exactly the thing this module exists to end.
|
|
62
|
+
function requireNode(node, what) {
|
|
63
|
+
if (typeof node !== 'object' || node === null || Array.isArray(node)) {
|
|
64
|
+
throw new EffortError('effort.policy-invalid', `effort.yml: ${what} must be a mapping.`);
|
|
65
|
+
}
|
|
66
|
+
return node;
|
|
67
|
+
}
|
|
68
|
+
function requireEnum(value, allowed, where) {
|
|
69
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
70
|
+
throw new EffortError('effort.policy-invalid', `effort.yml: ${where} is ${JSON.stringify(value)}; allowed: ${allowed.join(', ')}.`, { where, value, allowed: [...allowed] });
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
function requireNumber(value, where, min, max) {
|
|
75
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
|
|
76
|
+
throw new EffortError('effort.policy-invalid', `effort.yml: ${where} is ${JSON.stringify(value)}; expected a number in [${min}, ${max}].`, { where, value });
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function requireStringList(value, where, minLength) {
|
|
81
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
82
|
+
throw new EffortError('effort.policy-invalid', `effort.yml: ${where} must be a list of non-empty strings.`, { where });
|
|
83
|
+
}
|
|
84
|
+
if (value.length < minLength) {
|
|
85
|
+
throw new EffortError('effort.policy-invalid', `effort.yml: ${where} needs at least ${minLength} entr${minLength === 1 ? 'y' : 'ies'}.`, { where });
|
|
86
|
+
}
|
|
87
|
+
return value.map(String);
|
|
88
|
+
}
|
|
89
|
+
function parseProfile(node, tier) {
|
|
90
|
+
const t = requireNode(node, `tiers.${tier}`);
|
|
91
|
+
const summary = typeof t.summary === 'string' ? t.summary.trim() : '';
|
|
92
|
+
const graderRounds = requireNumber(t.grader_rounds, `tiers.${tier}.grader_rounds`, 1, 2);
|
|
93
|
+
if (!Number.isInteger(graderRounds)) {
|
|
94
|
+
throw new EffortError('effort.policy-invalid', `effort.yml: tiers.${tier}.grader_rounds must be a whole number of rounds.`);
|
|
95
|
+
}
|
|
96
|
+
const verifierSeverities = requireStringList(t.verifier_severities, `tiers.${tier}.verifier_severities`, 1);
|
|
97
|
+
// S1 is not a tier's to drop: a stop-the-line finding reaching the coder
|
|
98
|
+
// unrefuted is the round trip the adversarial pass exists to prevent, and it
|
|
99
|
+
// costs the same at every scale.
|
|
100
|
+
if (!verifierSeverities.includes('S1-stop-the-line')) {
|
|
101
|
+
throw new EffortError('effort.policy-invalid', `effort.yml: tiers.${tier}.verifier_severities must keep "S1-stop-the-line" — no tier may leave a stop-the-line finding unverified.`, { tier });
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
summary,
|
|
105
|
+
preCodeResearch: requireEnum(t.pre_code_research, PRE_CODE_RESEARCH, `tiers.${tier}.pre_code_research`),
|
|
106
|
+
preCodeUiux: requireEnum(t.pre_code_uiux, PRE_CODE_UIUX, `tiers.${tier}.pre_code_uiux`),
|
|
107
|
+
specReviewRounds: requireEnum(t.spec_review_rounds, SPEC_REVIEW_ROUNDS, `tiers.${tier}.spec_review_rounds`),
|
|
108
|
+
planQuorum: requireEnum(t.plan_quorum, PLAN_QUORUM, `tiers.${tier}.plan_quorum`),
|
|
109
|
+
graderRounds,
|
|
110
|
+
verifierSeverities,
|
|
111
|
+
verifierS3SpotCheckRatio: requireNumber(t.verifier_s3_spot_check_ratio, `tiers.${tier}.verifier_s3_spot_check_ratio`, 0, 1),
|
|
112
|
+
closingSpecReview: requireEnum(t.closing_spec_review, CLOSING_SPEC_REVIEW, `tiers.${tier}.closing_spec_review`),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function parseEffortPolicy(yamlText) {
|
|
116
|
+
const doc = requireNode(parseYaml(yamlText), 'the document');
|
|
117
|
+
const tiersNode = requireNode(doc.tiers, 'tiers');
|
|
118
|
+
// Presence of the whole table first, then each profile: an operator who
|
|
119
|
+
// deleted two tiers should be told that, not handed the first missing knob
|
|
120
|
+
// of the one tier they kept.
|
|
121
|
+
const missing = EFFORT_TIERS.filter((tier) => !(tier in tiersNode));
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
throw new EffortError('effort.policy-invalid', `effort.yml: tiers is missing ${missing.map((t) => `"${t}"`).join(', ')}. All of ${EFFORT_TIERS.join(', ')} must be declared — a missing tier is a silent default.`, { missing });
|
|
124
|
+
}
|
|
125
|
+
const tiers = {};
|
|
126
|
+
for (const tier of EFFORT_TIERS) {
|
|
127
|
+
tiers[tier] = parseProfile(tiersNode[tier], tier);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
version: typeof doc.version === 'number' ? doc.version : 1,
|
|
131
|
+
defaultTier: requireEnum(doc.default_tier, EFFORT_TIERS, 'default_tier'),
|
|
132
|
+
securityFloor: requireEnum(doc.security_floor, EFFORT_TIERS, 'security_floor'),
|
|
133
|
+
tiers,
|
|
134
|
+
invariants: requireStringList(doc.invariants, 'invariants', 1),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export function loadEffortPolicy(filePath = EFFORT_POLICY_PATH) {
|
|
138
|
+
return parseEffortPolicy(readFileSync(filePath, 'utf8'));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Pure, no I/O: decide which tier an epic runs at and hand back the profile
|
|
142
|
+
* plus the evidence for the decision.
|
|
143
|
+
*
|
|
144
|
+
* The floor only ever raises. An operator who asks for `huge` on a
|
|
145
|
+
* security-sensitive epic gets `huge`; one who asks for `small` gets the
|
|
146
|
+
* floor, and `floorApplied` says so rather than leaving them to notice their
|
|
147
|
+
* request was overridden by comparing two tables. Security sensitivity is read
|
|
148
|
+
* from the plan's LIVE tasks (D-113/D-185), never `plan.tasks`: a superseded
|
|
149
|
+
* task is history, and an epic must not be held at a higher tier by an ask it
|
|
150
|
+
* has already withdrawn.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveEffort(policy, securityPolicy, opts = {}) {
|
|
153
|
+
// A typo'd --effort must not degrade into the default: that is the same
|
|
154
|
+
// silence validatePlan rejects a typo'd plan field for.
|
|
155
|
+
if (opts.override !== undefined && !isEffortTier(opts.override)) {
|
|
156
|
+
throw new EffortError('effort.unknown-tier', `Effort tier "${opts.override}" is not one of ${EFFORT_TIERS.join(', ')} (factory/policies/effort.yml).`, { tier: opts.override, allowed: [...EFFORT_TIERS] });
|
|
157
|
+
}
|
|
158
|
+
const fromPlan = isEffortTier(opts.plan?.effort) ? opts.plan.effort : null;
|
|
159
|
+
const requested = opts.override ?? fromPlan;
|
|
160
|
+
const requestedFrom = opts.override !== undefined ? 'flag' : fromPlan !== null ? 'plan' : 'default';
|
|
161
|
+
const base = requested ?? policy.defaultTier;
|
|
162
|
+
const securityFloorEvaluated = opts.plan !== undefined;
|
|
163
|
+
const securityTriggers = opts.plan ? evaluatePlanSecurityTriggers(opts.plan, securityPolicy) : [];
|
|
164
|
+
const floorApplied = securityTriggers.length > 0 && tierRank(base) < tierRank(policy.securityFloor);
|
|
165
|
+
const effective = floorApplied ? policy.securityFloor : base;
|
|
166
|
+
return {
|
|
167
|
+
requested,
|
|
168
|
+
requestedFrom,
|
|
169
|
+
defaultTier: policy.defaultTier,
|
|
170
|
+
effective,
|
|
171
|
+
floorApplied,
|
|
172
|
+
securityFloor: policy.securityFloor,
|
|
173
|
+
securityFloorEvaluated,
|
|
174
|
+
securityTriggers,
|
|
175
|
+
reason: explain({
|
|
176
|
+
base,
|
|
177
|
+
effective,
|
|
178
|
+
floorApplied,
|
|
179
|
+
requestedFrom,
|
|
180
|
+
securityFloor: policy.securityFloor,
|
|
181
|
+
securityFloorEvaluated,
|
|
182
|
+
triggerCount: securityTriggers.length,
|
|
183
|
+
}),
|
|
184
|
+
profile: policy.tiers[effective],
|
|
185
|
+
invariants: policy.invariants,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function explain(facts) {
|
|
189
|
+
const source = facts.requestedFrom === 'default'
|
|
190
|
+
? `nobody named a tier, so the policy default "${facts.base}" applies`
|
|
191
|
+
: `the ${facts.requestedFrom} asked for "${facts.base}"`;
|
|
192
|
+
if (!facts.securityFloorEvaluated) {
|
|
193
|
+
return `${source}; the security floor was not evaluated — no plan was supplied, so this is the tier's profile and not a decision about a specific epic.`;
|
|
194
|
+
}
|
|
195
|
+
if (facts.floorApplied) {
|
|
196
|
+
return `${source}; raised to "${facts.effective}" by the security floor — ${facts.triggerCount} security trigger(s) fired on this plan's live tasks (crosscheck.yml plan_quorum).`;
|
|
197
|
+
}
|
|
198
|
+
if (facts.triggerCount === 0) {
|
|
199
|
+
return `${source}; no security trigger fired on this plan's live tasks, so the floor changed nothing.`;
|
|
200
|
+
}
|
|
201
|
+
return `${source}; ${facts.triggerCount} security trigger(s) fired, but "${facts.base}" is already at or above the "${facts.securityFloor}" floor.`;
|
|
202
|
+
}
|