@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,500 @@
|
|
|
1
|
+
import { compareLogOrder, eventTaskId, isLaterEvent } from './events.js';
|
|
2
|
+
import { bareTaskId, epicOfTaskId, isQualifiedTaskId, taskIdsMatch } from './taskId.js';
|
|
3
|
+
/**
|
|
4
|
+
* Roles whose dispatch constitutes a new round on the task.
|
|
5
|
+
*
|
|
6
|
+
* The split follows budgets.yml: `task.judges` names spec-reviewer, reviewer,
|
|
7
|
+
* verifier and grader, and `context_window.narrowing_roles` adds
|
|
8
|
+
* security-reviewer and merger — those roles read a diff, they do not produce
|
|
9
|
+
* the one the gate scores. planner/researcher/scribe produce no task diff
|
|
10
|
+
* either. What is left is what a "round" means: someone built again.
|
|
11
|
+
*/
|
|
12
|
+
export const BUILDER_ROLES = Object.freeze(['coder', 'tester', 'uiux']);
|
|
13
|
+
/** taxonomy.yml `model_tier`, ordered by capability. */
|
|
14
|
+
const TIER_RANK = Object.freeze({
|
|
15
|
+
small: 0,
|
|
16
|
+
mid: 1,
|
|
17
|
+
frontier: 2,
|
|
18
|
+
});
|
|
19
|
+
const TOP_TIER = 'frontier';
|
|
20
|
+
/** Event types that record the operator having answered (see rung 3). */
|
|
21
|
+
const OPERATOR_ANSWER_EVENTS = Object.freeze([
|
|
22
|
+
'user_prompt',
|
|
23
|
+
'waiver-granted',
|
|
24
|
+
'waiver-denied',
|
|
25
|
+
]);
|
|
26
|
+
function payloadString(payload, key) {
|
|
27
|
+
const value = payload[key];
|
|
28
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
29
|
+
}
|
|
30
|
+
function payloadOf(stored) {
|
|
31
|
+
return (stored.record.payload ?? {});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Every `gate-outcome` in the log, in order — gate.ts's terminal event per
|
|
35
|
+
* round, whatever it decided.
|
|
36
|
+
*
|
|
37
|
+
* The passes matter as much as the blocks. A gate outcome after a failed round
|
|
38
|
+
* is proof the task ran again, and it is proof the log keeps even when the
|
|
39
|
+
* dispatch that caused it went unrecorded — which is exactly the case the
|
|
40
|
+
* dogfood session is in. See checkModelTierRung.
|
|
41
|
+
*/
|
|
42
|
+
export function readGateRounds(events) {
|
|
43
|
+
const rounds = [];
|
|
44
|
+
for (const stored of events) {
|
|
45
|
+
const record = stored.record;
|
|
46
|
+
if (record.event_type !== 'gate-outcome')
|
|
47
|
+
continue;
|
|
48
|
+
const payload = payloadOf(stored);
|
|
49
|
+
rounds.push({
|
|
50
|
+
eventId: stored.event_id,
|
|
51
|
+
ts: record.ts ?? '',
|
|
52
|
+
taskId: eventTaskId(record),
|
|
53
|
+
outcome: payloadString(payload, 'outcome'),
|
|
54
|
+
reason: payloadString(payload, 'reason'),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return rounds;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A session's failed rounds, in log order.
|
|
61
|
+
*
|
|
62
|
+
* A failed round is one `gate-outcome` with outcome `blocked`. Passes are not
|
|
63
|
+
* rounds, and neither is a gate that never reached an outcome — an unrecorded
|
|
64
|
+
* gate run and a clean one look the same here, which is why gate.ts emits the
|
|
65
|
+
* event unconditionally.
|
|
66
|
+
*/
|
|
67
|
+
export function readFailedRounds(events) {
|
|
68
|
+
return readGateRounds(events).filter((round) => round.outcome === 'blocked');
|
|
69
|
+
}
|
|
70
|
+
/** A session's builder dispatches, in log order. Judges are not rounds. */
|
|
71
|
+
export function readBuilderDispatches(events) {
|
|
72
|
+
const dispatches = [];
|
|
73
|
+
for (const stored of events) {
|
|
74
|
+
const record = stored.record;
|
|
75
|
+
if (record.event_type !== 'dispatch_decision')
|
|
76
|
+
continue;
|
|
77
|
+
const payload = payloadOf(stored);
|
|
78
|
+
const role = payloadString(payload, 'agent_role');
|
|
79
|
+
if (!role || !BUILDER_ROLES.includes(role))
|
|
80
|
+
continue;
|
|
81
|
+
dispatches.push({
|
|
82
|
+
eventId: stored.event_id,
|
|
83
|
+
ts: record.ts ?? '',
|
|
84
|
+
// Both levels (D-245): a dispatch whose task id is only in its payload
|
|
85
|
+
// scopes out of `--task`, and the ladder then reports `unverifiable`
|
|
86
|
+
// for a task the log holds every round of.
|
|
87
|
+
taskId: eventTaskId(record),
|
|
88
|
+
role,
|
|
89
|
+
modelTier: payloadString(payload, 'model_tier'),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return dispatches;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The rung a given failure count puts a task on: the highest rung whose
|
|
96
|
+
* threshold it has reached, or null below the first one. Rungs with no
|
|
97
|
+
* numeric threshold are not reachable by counting and are skipped here — the
|
|
98
|
+
* audit reports them separately rather than guessing where they sit.
|
|
99
|
+
*/
|
|
100
|
+
export function escalationRungFor(failedRounds, ladder) {
|
|
101
|
+
let reached = null;
|
|
102
|
+
for (const rung of ladder) {
|
|
103
|
+
if (rung.failedRounds === null)
|
|
104
|
+
continue;
|
|
105
|
+
if (failedRounds < rung.failedRounds)
|
|
106
|
+
continue;
|
|
107
|
+
if (!reached || rung.failedRounds >= (reached.failedRounds ?? -1))
|
|
108
|
+
reached = rung;
|
|
109
|
+
}
|
|
110
|
+
return reached;
|
|
111
|
+
}
|
|
112
|
+
// Every question below is "which event is on which side of this round", and
|
|
113
|
+
// `ts` alone cannot answer it. appendEvent stamps `new Date().toISOString()`,
|
|
114
|
+
// so two writes in one millisecond share a timestamp, and a `gate-outcome`
|
|
115
|
+
// and the `dispatch_decision` that answers it are written back to back. On a
|
|
116
|
+
// tie a bare `<` or `>` is not a decision — it is whichever side the operator
|
|
117
|
+
// happens to be told about. compareLogOrder falls through to the index behind
|
|
118
|
+
// the event id, which is the line the log wrote, so it decides.
|
|
119
|
+
//
|
|
120
|
+
// The damage from not deciding is the D-249 inversion arriving by a second
|
|
121
|
+
// road. A tied retry was dropped by dispatchAfter AND picked up by
|
|
122
|
+
// dispatchBefore as the round's own failing dispatch, so the rung read "the
|
|
123
|
+
// task neither was dispatched again nor reached the gate again" —
|
|
124
|
+
// `not-applicable`, which counts as OK. A ladder skipped on the same tier
|
|
125
|
+
// then exits 0, with the retry that skipped it sitting in the log.
|
|
126
|
+
/** The last builder dispatch the log wrote before `round`; null when none did. */
|
|
127
|
+
function dispatchBefore(dispatches, round) {
|
|
128
|
+
let best = null;
|
|
129
|
+
for (const d of dispatches) {
|
|
130
|
+
if (compareLogOrder(d, round) >= 0)
|
|
131
|
+
continue;
|
|
132
|
+
if (!best || isLaterEvent(d, best))
|
|
133
|
+
best = d;
|
|
134
|
+
}
|
|
135
|
+
return best;
|
|
136
|
+
}
|
|
137
|
+
/** The first builder dispatch the log wrote after `round`. */
|
|
138
|
+
function dispatchAfter(dispatches, round) {
|
|
139
|
+
let best = null;
|
|
140
|
+
for (const d of dispatches) {
|
|
141
|
+
if (compareLogOrder(d, round) <= 0)
|
|
142
|
+
continue;
|
|
143
|
+
if (!best || isLaterEvent(best, d))
|
|
144
|
+
best = d;
|
|
145
|
+
}
|
|
146
|
+
return best;
|
|
147
|
+
}
|
|
148
|
+
/** The first gate outcome the log wrote after `round` — the task demonstrably ran again. */
|
|
149
|
+
function gateRoundAfter(rounds, round) {
|
|
150
|
+
let best = null;
|
|
151
|
+
for (const r of rounds) {
|
|
152
|
+
if (compareLogOrder(r, round) <= 0)
|
|
153
|
+
continue;
|
|
154
|
+
if (!best || isLaterEvent(best, r))
|
|
155
|
+
best = r;
|
|
156
|
+
}
|
|
157
|
+
return best;
|
|
158
|
+
}
|
|
159
|
+
function checkModelTierRung(base, trigger, dispatches, gateRounds) {
|
|
160
|
+
const failing = dispatchBefore(dispatches, trigger);
|
|
161
|
+
const retry = dispatchAfter(dispatches, trigger);
|
|
162
|
+
if (!retry) {
|
|
163
|
+
// Two different facts hide behind "no dispatch after the trigger". If the
|
|
164
|
+
// task was gated again, it plainly ran again and the log simply failed to
|
|
165
|
+
// record on what tier; calling that "never exercised" would turn a hole in
|
|
166
|
+
// the record into a clean bill of health. This is not hypothetical —
|
|
167
|
+
// dogfood-envkit-1's task-1b-parse-quotes blocked twice, then passed, with
|
|
168
|
+
// no dispatch_decision event anywhere for the task.
|
|
169
|
+
const resumed = gateRoundAfter(gateRounds, trigger);
|
|
170
|
+
if (resumed) {
|
|
171
|
+
return {
|
|
172
|
+
...base,
|
|
173
|
+
status: 'unverifiable',
|
|
174
|
+
detail: `The task was gated again at ${resumed.ts} after ${base.failedRounds} failed rounds, but no builder dispatch is recorded for that round, so the tier it ran on is unknown.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
...base,
|
|
179
|
+
status: 'not-applicable',
|
|
180
|
+
detail: `${base.failedRounds} failed rounds, and the task neither was dispatched again nor reached the gate again — the tier rung was never exercised.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (!failing) {
|
|
184
|
+
return {
|
|
185
|
+
...base,
|
|
186
|
+
status: 'unverifiable',
|
|
187
|
+
detail: `No builder dispatch precedes the failed round at ${trigger.ts}, so there is no tier for the retry to be higher than.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (!failing.modelTier || !retry.modelTier) {
|
|
191
|
+
const which = failing.modelTier ? 'retry' : 'failing';
|
|
192
|
+
return {
|
|
193
|
+
...base,
|
|
194
|
+
status: 'unverifiable',
|
|
195
|
+
detail: `The ${which} dispatch records no model_tier, so the tier escalation cannot be evaluated.`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const from = TIER_RANK[failing.modelTier];
|
|
199
|
+
const to = TIER_RANK[retry.modelTier];
|
|
200
|
+
if (from === undefined || to === undefined) {
|
|
201
|
+
const unknown = from === undefined ? failing.modelTier : retry.modelTier;
|
|
202
|
+
return {
|
|
203
|
+
...base,
|
|
204
|
+
status: 'unverifiable',
|
|
205
|
+
detail: `model_tier "${unknown}" is not in taxonomy.yml's model_tier, so the tiers cannot be ordered.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (to > from) {
|
|
209
|
+
return {
|
|
210
|
+
...base,
|
|
211
|
+
status: 'ok',
|
|
212
|
+
detail: `Retried on ${retry.modelTier} after failing on ${failing.modelTier}.`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (failing.modelTier === TOP_TIER) {
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
status: 'not-applicable',
|
|
219
|
+
detail: `The failing round already ran on ${TOP_TIER}; the tier rung has no higher tier to move to, and the ladder's remaining answer is the operator rung.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
...base,
|
|
224
|
+
status: 'violation',
|
|
225
|
+
detail: `Retried on ${retry.modelTier} after failing on ${failing.modelTier} — the ladder requires a strictly higher tier at this rung.`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function checkOperatorRung(base, trigger, dispatches, gateRounds, operatorAnswers) {
|
|
229
|
+
// Either kind of event proves the factory carried on: a builder dispatch, or
|
|
230
|
+
// a gate outcome for a round whose dispatch went unrecorded. The bound is
|
|
231
|
+
// breached by the work continuing, not by which event happened to capture it,
|
|
232
|
+
// so take whichever came first.
|
|
233
|
+
const retry = dispatchAfter(dispatches, trigger);
|
|
234
|
+
const resumed = gateRoundAfter(gateRounds, trigger);
|
|
235
|
+
const carriedOn = retry && resumed
|
|
236
|
+
? compareLogOrder(retry, resumed) <= 0
|
|
237
|
+
? retry
|
|
238
|
+
: resumed
|
|
239
|
+
: (retry ?? resumed);
|
|
240
|
+
if (!carriedOn) {
|
|
241
|
+
return {
|
|
242
|
+
...base,
|
|
243
|
+
status: 'ok',
|
|
244
|
+
detail: `${base.failedRounds} failed rounds and nothing after them — the bound held. The log records no operator-handoff event, so this check asserts the bound, not the notification.`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const evidence = carriedOn === retry ? `Dispatched again at ${carriedOn.ts}` : `Gated again at ${carriedOn.ts}`;
|
|
248
|
+
// Log order at both ends, for the same reason the finders use it. An answer
|
|
249
|
+
// the operator typed in the millisecond the factory resumed in is an answer
|
|
250
|
+
// that came first if the log wrote it first, and a strict `<` on `ts` read it
|
|
251
|
+
// as no answer at all — a violation reported against an honest run.
|
|
252
|
+
const answered = operatorAnswers.some((a) => compareLogOrder(a, trigger) > 0 && compareLogOrder(a, carriedOn) < 0);
|
|
253
|
+
if (answered) {
|
|
254
|
+
return {
|
|
255
|
+
...base,
|
|
256
|
+
status: 'ok',
|
|
257
|
+
detail: `The operator answered between the failed round at ${trigger.ts} and the next round at ${carriedOn.ts}.`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
...base,
|
|
262
|
+
status: 'violation',
|
|
263
|
+
detail: `${evidence} after ${base.failedRounds} failed rounds with no operator answer in between — the ladder was looped past its bound (coordination.livelock).`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function checkOneRung(taskId, rung, rounds, dispatches, gateRounds, operatorAnswers) {
|
|
267
|
+
const failedRounds = rounds.length;
|
|
268
|
+
const base = {
|
|
269
|
+
taskId,
|
|
270
|
+
rung: rung.rung,
|
|
271
|
+
triggerRounds: rung.failedRounds,
|
|
272
|
+
failedRounds,
|
|
273
|
+
triggerEventId: null,
|
|
274
|
+
};
|
|
275
|
+
if (rung.failedRounds === null) {
|
|
276
|
+
return {
|
|
277
|
+
...base,
|
|
278
|
+
status: 'unverifiable',
|
|
279
|
+
detail: `Rung ${rung.rung} declares no numeric failed_rounds, so nothing can tell whether it tripped.`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (failedRounds < rung.failedRounds)
|
|
283
|
+
return null;
|
|
284
|
+
const trigger = rounds[rung.failedRounds - 1];
|
|
285
|
+
if (!trigger)
|
|
286
|
+
return null;
|
|
287
|
+
const withTrigger = { ...base, triggerEventId: trigger.eventId };
|
|
288
|
+
if (rung.action === null) {
|
|
289
|
+
return {
|
|
290
|
+
...withTrigger,
|
|
291
|
+
status: 'not-applicable',
|
|
292
|
+
detail: `Rung ${rung.rung} declares no action (${rung.trigger || 'no trigger recorded'}); there is nothing for the log to evidence.`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (rung.enforce === null) {
|
|
296
|
+
return {
|
|
297
|
+
...withTrigger,
|
|
298
|
+
status: 'unverifiable',
|
|
299
|
+
detail: `Rung ${rung.rung} declares the action "${rung.action}" with no enforce keyword, so the audit cannot check it.`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (rung.enforce === 'model-tier') {
|
|
303
|
+
return checkModelTierRung(withTrigger, trigger, dispatches, gateRounds);
|
|
304
|
+
}
|
|
305
|
+
if (rung.enforce === 'operator') {
|
|
306
|
+
return checkOperatorRung(withTrigger, trigger, dispatches, gateRounds, operatorAnswers);
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
...withTrigger,
|
|
310
|
+
status: 'unverifiable',
|
|
311
|
+
detail: `Rung ${rung.rung} declares enforce: "${rung.enforce}", which this audit does not implement.`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Assert budgets.yml's escalation ladder against what the log actually
|
|
316
|
+
* records. Every rung a task reached is checked, not just the highest one: a
|
|
317
|
+
* task that skipped the tier rung and later reached the operator did skip the
|
|
318
|
+
* tier rung, and the ladder's own note calls that a coordination error.
|
|
319
|
+
*/
|
|
320
|
+
export function checkEscalationLadder(events, ladder, options) {
|
|
321
|
+
const scopedTask = options.taskId ?? null;
|
|
322
|
+
// D-181: an operator who types either spelling of a task id means the same
|
|
323
|
+
// task, and taskIdsMatch already owns that rule — including its limit, that
|
|
324
|
+
// two DIFFERENT qualified ids stay different tasks even when their bare
|
|
325
|
+
// halves collide. Raw `===` here answered `--task epic-1/task-2` with the
|
|
326
|
+
// subset of rounds that happened to be logged qualified.
|
|
327
|
+
const inScope = (taskId) => scopedTask === null || (taskId !== null && taskIdsMatch(taskId, scopedTask));
|
|
328
|
+
const allGateRounds = readGateRounds(events).filter((r) => inScope(r.taskId));
|
|
329
|
+
const allRounds = allGateRounds.filter((r) => r.outcome === 'blocked');
|
|
330
|
+
const allBuilderDispatches = readBuilderDispatches(events);
|
|
331
|
+
const dispatches = allBuilderDispatches.filter((d) => inScope(d.taskId));
|
|
332
|
+
const operatorAnswers = events
|
|
333
|
+
.filter((e) => OPERATOR_ANSWER_EVENTS.includes(e.record.event_type))
|
|
334
|
+
.filter((e) => e.record.event_type === 'user_prompt' || inScope(eventTaskId(e.record)))
|
|
335
|
+
.map((e) => ({ eventId: e.event_id, ts: e.record.ts ?? '' }));
|
|
336
|
+
const report = (checks) => ({
|
|
337
|
+
sessionId: options.sessionId,
|
|
338
|
+
taskId: scopedTask,
|
|
339
|
+
roundsExamined: allRounds.length,
|
|
340
|
+
checks,
|
|
341
|
+
ok: checks.every((c) => c.status === 'ok' || c.status === 'not-applicable'),
|
|
342
|
+
});
|
|
343
|
+
// A ladder with no rungs cannot be satisfied OR violated, and reporting that
|
|
344
|
+
// as a pass would advertise an audit nobody configured.
|
|
345
|
+
if (ladder.length === 0) {
|
|
346
|
+
return report([
|
|
347
|
+
{
|
|
348
|
+
taskId: '*',
|
|
349
|
+
rung: 0,
|
|
350
|
+
triggerRounds: null,
|
|
351
|
+
failedRounds: allRounds.length,
|
|
352
|
+
status: 'unverifiable',
|
|
353
|
+
triggerEventId: null,
|
|
354
|
+
detail: 'budgets.yml declares no escalation_ladder.rungs, so the ladder asserts nothing.',
|
|
355
|
+
},
|
|
356
|
+
]);
|
|
357
|
+
}
|
|
358
|
+
const checks = [];
|
|
359
|
+
// Rounds with no task id can't join any task's ladder. They are a defect in
|
|
360
|
+
// the record — gate.ts always passes a task id — and dropping them would
|
|
361
|
+
// shrink the audit to fit the damage.
|
|
362
|
+
const orphans = allRounds.filter((r) => r.taskId === null);
|
|
363
|
+
if (orphans.length > 0) {
|
|
364
|
+
checks.push({
|
|
365
|
+
taskId: '*',
|
|
366
|
+
rung: 0,
|
|
367
|
+
triggerRounds: null,
|
|
368
|
+
failedRounds: orphans.length,
|
|
369
|
+
status: 'unverifiable',
|
|
370
|
+
triggerEventId: orphans[0]?.eventId ?? null,
|
|
371
|
+
detail: `${orphans.length} blocked gate-outcome event(s) carry no task id, so they cannot be counted against any task's ladder.`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// D-249: the same rule as the rounds above, for the other half of the
|
|
375
|
+
// evidence. A dispatch with no task id was dropped per task by
|
|
376
|
+
// `d.taskId !== null` below and counted nowhere, and the two losses do not
|
|
377
|
+
// cost the same. A round the audit loses is a round it cannot judge; a
|
|
378
|
+
// dispatch it loses is the retry a rung is judged BY, so the answer does not
|
|
379
|
+
// weaken, it inverts: the rung reads "the task neither was dispatched again
|
|
380
|
+
// nor reached the gate again", which is `not-applicable`, which counts as
|
|
381
|
+
// OK. A ladder skipped on the same tier then exits 0.
|
|
382
|
+
//
|
|
383
|
+
// Read from the unscoped list on purpose. `inScope` drops a null task id
|
|
384
|
+
// before any task sees it, so under `--task` the one question being asked
|
|
385
|
+
// would be answered from a set the audit already knows is short.
|
|
386
|
+
//
|
|
387
|
+
// Builder roles only (readBuilderDispatches). A planner, a spec-reviewer and
|
|
388
|
+
// a scribe are dispatched for the epic and never carried a task id; naming
|
|
389
|
+
// them here would make every honest run unverifiable.
|
|
390
|
+
const unscopedDispatches = allBuilderDispatches.filter((d) => d.taskId === null);
|
|
391
|
+
if (unscopedDispatches.length > 0) {
|
|
392
|
+
checks.push({
|
|
393
|
+
taskId: '*',
|
|
394
|
+
rung: 0,
|
|
395
|
+
triggerRounds: null,
|
|
396
|
+
// Rounds, not dispatches: this check reports none, and the count that
|
|
397
|
+
// matters is in the detail where it cannot be read as a round.
|
|
398
|
+
failedRounds: 0,
|
|
399
|
+
status: 'unverifiable',
|
|
400
|
+
triggerEventId: unscopedDispatches[0]?.eventId ?? null,
|
|
401
|
+
detail: `${unscopedDispatches.length} builder dispatch(es) carry no task id, so no task's ladder can count them as the retry that answers a rung.`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
// D-181: the log writes one task both ways — "<epic>/<task>" and bare
|
|
405
|
+
// "<task>" — because it is append-only and predates D-46/P9-10, so the two
|
|
406
|
+
// spellings interleave rather than splitting at a cutover. Grouping on the
|
|
407
|
+
// raw string therefore built TWO ladders for one task: the real one
|
|
408
|
+
// undercounted its rounds, and a phantom one reported "neither dispatched
|
|
409
|
+
// again nor reached the gate again" while every dispatch sat under the other
|
|
410
|
+
// spelling. That answer is `not-applicable`, which counts as OK, so half of
|
|
411
|
+
// a violated ladder passed the audit. Every qualified id teaches the audit
|
|
412
|
+
// which epic a bare name belongs to, and the bare rounds join that ladder.
|
|
413
|
+
const epicsByBare = new Map();
|
|
414
|
+
const learnEpic = (taskId) => {
|
|
415
|
+
if (taskId === null)
|
|
416
|
+
return;
|
|
417
|
+
const epic = epicOfTaskId(taskId);
|
|
418
|
+
if (epic === null)
|
|
419
|
+
return;
|
|
420
|
+
const bare = bareTaskId(taskId);
|
|
421
|
+
const seen = epicsByBare.get(bare);
|
|
422
|
+
if (seen)
|
|
423
|
+
seen.add(epic);
|
|
424
|
+
else
|
|
425
|
+
epicsByBare.set(bare, new Set([epic]));
|
|
426
|
+
};
|
|
427
|
+
for (const round of allGateRounds)
|
|
428
|
+
learnEpic(round.taskId);
|
|
429
|
+
for (const dispatched of dispatches)
|
|
430
|
+
learnEpic(dispatched.taskId);
|
|
431
|
+
// The qualified spelling of a task id, or null when a bare name is one two
|
|
432
|
+
// epics both use. Merging those would trade a silent undercount for a silent
|
|
433
|
+
// merge, which is the trade taskIdsMatch refuses to make.
|
|
434
|
+
const canonicalTaskId = (taskId) => {
|
|
435
|
+
if (isQualifiedTaskId(taskId))
|
|
436
|
+
return taskId;
|
|
437
|
+
const epics = epicsByBare.get(taskId);
|
|
438
|
+
if (epics === undefined)
|
|
439
|
+
return taskId;
|
|
440
|
+
if (epics.size > 1)
|
|
441
|
+
return null;
|
|
442
|
+
const [epic] = epics;
|
|
443
|
+
return epic === undefined ? taskId : `${epic}/${taskId}`;
|
|
444
|
+
};
|
|
445
|
+
const byTask = new Map();
|
|
446
|
+
const unplaceable = [];
|
|
447
|
+
const unplaceableNames = new Set();
|
|
448
|
+
for (const round of allRounds) {
|
|
449
|
+
if (round.taskId === null)
|
|
450
|
+
continue;
|
|
451
|
+
const canonical = canonicalTaskId(round.taskId);
|
|
452
|
+
if (canonical === null) {
|
|
453
|
+
unplaceable.push(round);
|
|
454
|
+
unplaceableNames.add(round.taskId);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const list = byTask.get(canonical);
|
|
458
|
+
if (list)
|
|
459
|
+
list.push(round);
|
|
460
|
+
else
|
|
461
|
+
byTask.set(canonical, [round]);
|
|
462
|
+
}
|
|
463
|
+
// Same fail-closed answer as the orphans above, for the same reason: a round
|
|
464
|
+
// the audit cannot attribute is reported as unattributable, not guessed onto
|
|
465
|
+
// one epic and not quietly dropped.
|
|
466
|
+
if (unplaceable.length > 0) {
|
|
467
|
+
const names = [...unplaceableNames].sort().join(', ');
|
|
468
|
+
checks.push({
|
|
469
|
+
taskId: '*',
|
|
470
|
+
rung: 0,
|
|
471
|
+
triggerRounds: null,
|
|
472
|
+
failedRounds: unplaceable.length,
|
|
473
|
+
status: 'unverifiable',
|
|
474
|
+
triggerEventId: unplaceable[0]?.eventId ?? null,
|
|
475
|
+
detail: `${unplaceable.length} blocked gate-outcome event(s) name a task without its epic (${names}), and more than one epic uses that name, so they cannot be counted against one task's ladder.`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (byTask.size === 0 && orphans.length === 0 && unplaceable.length === 0) {
|
|
479
|
+
checks.push({
|
|
480
|
+
taskId: scopedTask ?? '*',
|
|
481
|
+
rung: 0,
|
|
482
|
+
triggerRounds: null,
|
|
483
|
+
failedRounds: 0,
|
|
484
|
+
status: 'not-applicable',
|
|
485
|
+
triggerEventId: null,
|
|
486
|
+
detail: 'No failed rounds in scope; no task reached the first rung.',
|
|
487
|
+
});
|
|
488
|
+
return report(checks);
|
|
489
|
+
}
|
|
490
|
+
for (const [taskId, rounds] of byTask) {
|
|
491
|
+
const taskDispatches = dispatches.filter((d) => d.taskId !== null && canonicalTaskId(d.taskId) === taskId);
|
|
492
|
+
const taskGateRounds = allGateRounds.filter((r) => r.taskId !== null && canonicalTaskId(r.taskId) === taskId);
|
|
493
|
+
for (const rung of ladder) {
|
|
494
|
+
const check = checkOneRung(taskId, rung, rounds, taskDispatches, taskGateRounds, operatorAnswers);
|
|
495
|
+
if (check)
|
|
496
|
+
checks.push(check);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return report(checks);
|
|
500
|
+
}
|