@juzser/blacksmith 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,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
+ }