@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,399 @@
1
+ // `lessons.md` only ever grows. Nothing in the factory reads an entry back and
2
+ // asks whether it still does anything, so approval is the last check a lesson
3
+ // ever faces, and a corpus that has been append-only for a hundred approvals is
4
+ // assumed to be a hundred lessons' worth of memory. It is not. Some of it is
5
+ // dead on arrival, and the two ways an entry dies need different evidence.
6
+ //
7
+ // STRUCTURAL DEATH, provable from the corpus alone. `findMatchingLesson`
8
+ // (severity.ts) is first-match-wins: it walks the corpus in order and returns
9
+ // the first entry whose category equals the finding's and whose glob covers the
10
+ // file. So corpus ORDER is load-bearing, and an entry that comes after one
11
+ // covering everything it covers can never be returned. That is dead code in a
12
+ // file no compiler reads — and the commonest instance of it is the one nobody
13
+ // would spot by eye, a `stack-wide` entry (`claim_path: **`) approved before a
14
+ // narrower entry in the same category, which silences every entry after it.
15
+ // This needs no log, no run, and no waiting: it is a property of the text.
16
+ //
17
+ // EVIDENTIAL DEATH: an entry that CAN fire and never has. This needs the log,
18
+ // and it needs the log to be able to say the entry was LOADED. Otherwise
19
+ // "never fired" and "was never installed" produce the same reading, and pruning
20
+ // on it deletes exactly the lessons that never got their chance. `--lessons` is
21
+ // optional (cli.ts), so a gate that ran without it decided every finding
22
+ // holding nothing at all.
23
+ //
24
+ // Hence the same construction sameMistakeKpi.ts uses: the instrument recorded
25
+ // next to the reading, and an `unverifiable` that outranks a clean-looking
26
+ // list. The asymmetry runs the same way too. A recorded firing is a FACT —
27
+ // `matched_lesson_id` cannot be written by a gate that did not hold the lesson,
28
+ // so `keep` survives any hole in the record, including every event written
29
+ // before `lesson_ids_escalating` existed. `idle` is a claim ABOUT the record,
30
+ // and is only honest once the record shows the entry was there to fire.
31
+ //
32
+ // Nothing here deletes anything. `retire` is a recommendation printed for an
33
+ // operator, because a lesson is a human's standing instruction and the case for
34
+ // dropping one is a case to be read, not a mutation to be applied. §9.6 already
35
+ // says as much about supersession: a contradiction needs a human's call.
36
+ import picomatch from 'picomatch';
37
+ import { globsOverlap } from './claims.js';
38
+ import { jaccardSimilarity, polarityDiffers, shingles } from './lessons.js';
39
+ import { lessonReach } from './sameMistakeKpi.js';
40
+ import { canEscalate, isFileScoped, lessonCoversFile } from './severity.js';
41
+ /**
42
+ * The bar for "these two statements are about the same thing".
43
+ *
44
+ * Deliberately NOT `scheduler.yml`'s `novelty_jaccard_threshold`, and the
45
+ * difference is the whole reason this constant exists rather than a reuse.
46
+ * The novelty gate asks "is this a near-verbatim restatement?" over word
47
+ * TRIGRAMS at 0.8, and `checkNovelty` only consults polarity once that bar is
48
+ * cleared. On trigrams, flipping one interior word — the sharpest contradiction
49
+ * there is, "always X" against "never X" — changes three shingles out of n-2
50
+ * and scores about 0.45 on a ten-word statement. So the guard is calibrated to
51
+ * miss precisely the case it is named for, and does: `lessons.ts`'s header
52
+ * already records the polarity marker list as a residual limitation, but the
53
+ * threshold in front of it is the tighter of the two gates.
54
+ *
55
+ * Contradiction asks the weaker question — same subject, opposite instruction —
56
+ * so it reads UNIGRAMS, where a one-word flip in a ten-word statement scores
57
+ * 0.8 and two unrelated statements score near zero. 0.5 sits between them with
58
+ * room on both sides.
59
+ *
60
+ * This does not make the polarity signal itself any better. Two statements that
61
+ * contradict each other without using one of six marker words are still
62
+ * invisible here, and always were.
63
+ */
64
+ const TOPIC_SIMILARITY_THRESHOLD = 0.5;
65
+ /**
66
+ * Whether `outer` provably covers every path `inner` covers — the condition
67
+ * under which the first-match walk can never reach `inner`.
68
+ *
69
+ * Three cases, each a proof and not a heuristic:
70
+ * 1. the same glob, so trivially the same set;
71
+ * 2. `**`, which picomatch matches against every path, slashes included —
72
+ * the `stack-wide` scope's compiled claim path, and the case that matters;
73
+ * 3. `inner` has no glob magic at all, so it denotes exactly one path, and
74
+ * `outer` either matches that path or does not.
75
+ *
76
+ * Everything else returns false and is reported as overlap instead. General
77
+ * glob containment is not decidable by inspection, and the cost of guessing
78
+ * wrong here is a recommendation to delete a live lesson — so this errs the
79
+ * only direction it can afford to.
80
+ */
81
+ export function coversEntirely(outer, inner) {
82
+ if (outer === inner)
83
+ return true;
84
+ if (outer === '**')
85
+ return true;
86
+ if (!picomatch.scan(inner).isGlob)
87
+ return picomatch(outer)(inner);
88
+ return false;
89
+ }
90
+ /**
91
+ * Whether two entries could ever both be in force. A contradiction between
92
+ * entries that can never meet is not a contradiction.
93
+ *
94
+ * File-scoped pairs meet where their globs overlap. Role- and case-scoped
95
+ * pairs meet on an equal selector, because that is what `lessonsForDispatch`
96
+ * filters them by. A file-scoped entry and a role-scoped one do reach the same
97
+ * prompt whenever that role's scope list holds both, but calling every such
98
+ * pair a possible contradiction would put most of the corpus in front of the
99
+ * polarity check, which is crude enough without being asked to arbitrate the
100
+ * whole corpus against itself.
101
+ */
102
+ function couldBothApply(a, b) {
103
+ if (isFileScoped(a) && isFileScoped(b))
104
+ return globsOverlap(a.claimPath, b.claimPath);
105
+ if (a.scope === 'agent-role' && b.scope === 'agent-role')
106
+ return a.agentRole === b.agentRole;
107
+ if (a.scope === 'case-type' && b.scope === 'case-type')
108
+ return a.caseType === b.caseType;
109
+ return false;
110
+ }
111
+ function livenessFor(entry) {
112
+ // Order is precedence, and the two corpus-level readings come first because
113
+ // they hold whatever the log says. An unreachable entry that somehow shows
114
+ // firings would mean the corpus changed since those runs, not that the proof
115
+ // is wrong — and `firings` is still reported next to it either way.
116
+ if (!entry.escalates)
117
+ return 'dispatch-only';
118
+ if (entry.shadowedBy.length > 0 && entry.firings === 0)
119
+ return 'unreachable';
120
+ if (entry.firings > 0)
121
+ return 'firing';
122
+ if (entry.opportunities > 0)
123
+ return 'idle';
124
+ return 'unmeasured';
125
+ }
126
+ function recommendationFor(liveness, contradicts) {
127
+ // A contradiction outranks everything else an entry could be. Two standing
128
+ // instructions that disagree are a hazard whether or not either one fires,
129
+ // and §9.6 reserves the resolution for a human, so no other reading here may
130
+ // quietly close the question first.
131
+ if (contradicts.length > 0)
132
+ return 'review';
133
+ switch (liveness) {
134
+ case 'unreachable':
135
+ return 'retire';
136
+ case 'firing':
137
+ return 'keep';
138
+ case 'idle':
139
+ return 'rescope';
140
+ default:
141
+ return 'no-evidence';
142
+ }
143
+ }
144
+ function entryDetail(entry) {
145
+ switch (entry.liveness) {
146
+ case 'dispatch-only':
147
+ return (`Not part of the escalation match — ${entry.category === '' ? 'it names no finding_category' : `its ${entry.scope} scope has no file to match against`}. ` +
148
+ 'It is spliced into role prompts instead, which this audit cannot measure.');
149
+ case 'unreachable':
150
+ return (`Never reachable: ${entry.shadowedBy.join(', ')} come(s) earlier in the corpus with the ` +
151
+ `same category and provably cover(s) every path "${entry.claimPath}" covers, and the ` +
152
+ 'match returns the first hit. Either drop this entry or narrow the earlier one.');
153
+ case 'firing':
154
+ return `Matched ${entry.firings} recorded decision(s).`;
155
+ case 'idle':
156
+ return (`${entry.opportunities} decision(s) fell in this entry's category and inside its glob ` +
157
+ `while the gate held it, and every one went to ${[...entry.outmatchedBy].join(', ')}. ` +
158
+ 'Its glob or its position in the corpus is wrong.');
159
+ default:
160
+ return 'No decision in the record fell inside its scope; nothing here argues either way.';
161
+ }
162
+ }
163
+ function describe(report) {
164
+ const head = `${report.entries.length} compiled lesson(s): ${report.counts.keep} firing, ` +
165
+ `${report.counts.rescope} idle, ${report.counts.retire} unreachable, ` +
166
+ `${report.counts.review} needing a human, ${report.counts['no-evidence']} unmeasured.`;
167
+ const defects = [];
168
+ const retiring = report.entries
169
+ .filter((e) => e.recommendation === 'retire')
170
+ .map((e) => e.lessonId);
171
+ if (retiring.length > 0) {
172
+ defects.push(`${retiring.join(', ')} can never be returned by the match — an earlier same-category ` +
173
+ 'entry covers everything they cover');
174
+ }
175
+ const rescoping = report.entries
176
+ .filter((e) => e.recommendation === 'rescope')
177
+ .map((e) => e.lessonId);
178
+ if (rescoping.length > 0) {
179
+ defects.push(`${rescoping.join(', ')} had decisions inside their scope and matched none`);
180
+ }
181
+ if (report.contradictions.length > 0) {
182
+ defects.push(`${report.contradictions.map((c) => `${c.a} vs ${c.b}`).join('; ')} give opposite ` +
183
+ 'instructions where both apply');
184
+ }
185
+ if (report.duplicateIds.length > 0) {
186
+ defects.push(`${report.duplicateIds.join(', ')} appear(s) twice, so every attribution to that id is ambiguous`);
187
+ }
188
+ const holes = [];
189
+ if (report.reach.escalating === 0) {
190
+ holes.push(`none of the ${report.reach.total} entries can escalate anything ` +
191
+ `(${report.reach.withoutCategory} name no finding_category, ` +
192
+ `${report.reach.nonFileScoped} are not file-scoped), so no reading here is about the work`);
193
+ }
194
+ if (report.intakesWithoutLessonIds > 0) {
195
+ holes.push(`${report.intakesWithoutLessonIds} intake(s) record no lesson_ids_escalating, so the log ` +
196
+ 'cannot say which entries were loaded and an entry that never fired may never have been present');
197
+ }
198
+ if (report.decisionsWithoutContext > 0) {
199
+ holes.push(`${report.decisionsWithoutContext} decision(s) carry no finding_category or file_path and ` +
200
+ 'cannot be placed against any entry');
201
+ }
202
+ const defectText = defects.length > 0 ? ` Defects: ${defects.join('; ')}.` : '';
203
+ const holeText = holes.length > 0 ? ` Holes: ${holes.join('; ')}.` : '';
204
+ switch (report.status) {
205
+ case 'defective':
206
+ return (`${head} The corpus contradicts or silences itself, and that is a property of the text ` +
207
+ `rather than of any run, so it stands whatever the log is missing.${defectText}${holeText}`);
208
+ case 'unverifiable':
209
+ return (`${head} Nothing here shows an entry failing to earn its place, but neither can it show ` +
210
+ `one earning it: the record cannot say what the gate was holding.${holeText}`);
211
+ default:
212
+ return `${head} Every entry is reachable, consistent, and accounted for in the record.`;
213
+ }
214
+ }
215
+ /**
216
+ * Read the compiled corpus back and say, per entry, whether it can fire, whether
217
+ * it has, and whether it disagrees with anything else in the file.
218
+ *
219
+ * Read-only over the log and over `lessons.md` — it recommends, and removes
220
+ * nothing. Fails the report on anything but `clean`, for the reason
221
+ * sameMistakeKpi.ts fails on anything but `on-target`: a corpus that cannot be
222
+ * read is not a corpus that is fine.
223
+ */
224
+ export function auditLessons(events, lessons, options) {
225
+ const topicThreshold = options.topicThreshold ?? TOPIC_SIMILARITY_THRESHOLD;
226
+ // --- the log --------------------------------------------------------------
227
+ const tallies = new Map();
228
+ const tallyFor = (id) => {
229
+ let tally = tallies.get(id);
230
+ if (tally === undefined) {
231
+ tally = { opportunities: 0, firings: 0, outmatchedBy: new Set() };
232
+ tallies.set(id, tally);
233
+ }
234
+ return tally;
235
+ };
236
+ let intakesWithoutLessonIds = 0;
237
+ let decisionsWithoutContext = 0;
238
+ for (const { record } of events) {
239
+ if (record.event_type !== 'severity-decisions')
240
+ continue;
241
+ const payload = record.payload;
242
+ const held = payload.lesson_ids_escalating;
243
+ if (!Array.isArray(held))
244
+ intakesWithoutLessonIds += 1;
245
+ const heldSet = new Set(Array.isArray(held) ? held : []);
246
+ for (const decision of payload.decisions ?? []) {
247
+ const matched = decision.matched_lesson_id ?? null;
248
+ // A firing attests to its own instrument: the id could not have been
249
+ // written by a gate that did not hold the lesson. So it is counted before
250
+ // any check on what else this event recorded, and counted even when the
251
+ // event predates every field below it.
252
+ if (matched !== null)
253
+ tallyFor(matched).firings += 1;
254
+ const category = decision.finding_category;
255
+ const filePath = decision.file_path;
256
+ if (typeof category !== 'string' || typeof filePath !== 'string') {
257
+ decisionsWithoutContext += 1;
258
+ continue;
259
+ }
260
+ // An opportunity is only an opportunity for an entry the gate was
261
+ // holding. Without that, "did not match" is a statement about the
262
+ // dispatch and not about the entry.
263
+ for (const lesson of lessons) {
264
+ if (!heldSet.has(lesson.lessonId))
265
+ continue;
266
+ if (!canEscalate(lesson))
267
+ continue;
268
+ if (lesson.category !== category)
269
+ continue;
270
+ if (!lessonCoversFile(lesson, filePath))
271
+ continue;
272
+ const tally = tallyFor(lesson.lessonId);
273
+ tally.opportunities += 1;
274
+ if (matched !== null && matched !== lesson.lessonId)
275
+ tally.outmatchedBy.add(matched);
276
+ }
277
+ }
278
+ }
279
+ // --- the corpus -----------------------------------------------------------
280
+ const seen = new Set();
281
+ const duplicateIds = [];
282
+ for (const lesson of lessons) {
283
+ if (seen.has(lesson.lessonId)) {
284
+ if (!duplicateIds.includes(lesson.lessonId))
285
+ duplicateIds.push(lesson.lessonId);
286
+ }
287
+ seen.add(lesson.lessonId);
288
+ }
289
+ const contradictions = [];
290
+ const contradictsBy = new Map();
291
+ const unigrams = lessons.map((lesson) => shingles(lesson.statement, 1));
292
+ for (let i = 0; i < lessons.length; i += 1) {
293
+ for (let j = i + 1; j < lessons.length; j += 1) {
294
+ const a = lessons[i];
295
+ const b = lessons[j];
296
+ if (!couldBothApply(a, b))
297
+ continue;
298
+ if (!polarityDiffers(a.statement, b.statement))
299
+ continue;
300
+ const similarity = jaccardSimilarity(unigrams[i], unigrams[j]);
301
+ if (similarity < topicThreshold)
302
+ continue;
303
+ contradictions.push({
304
+ a: a.lessonId,
305
+ b: b.lessonId,
306
+ similarity,
307
+ reason: `${(similarity * 100).toFixed(0)}% word overlap with opposing imperative polarity, ` +
308
+ 'over scopes that can both be in force at once',
309
+ });
310
+ contradictsBy.set(a.lessonId, [...(contradictsBy.get(a.lessonId) ?? []), b.lessonId]);
311
+ contradictsBy.set(b.lessonId, [...(contradictsBy.get(b.lessonId) ?? []), a.lessonId]);
312
+ }
313
+ }
314
+ const counts = {
315
+ keep: 0,
316
+ review: 0,
317
+ retire: 0,
318
+ rescope: 0,
319
+ 'no-evidence': 0,
320
+ };
321
+ const entries = lessons.map((lesson, index) => {
322
+ const shadowedBy = [];
323
+ const overlapsWith = [];
324
+ if (canEscalate(lesson)) {
325
+ for (let j = 0; j < index; j += 1) {
326
+ const prior = lessons[j];
327
+ // Exactly the conditions the match walk applies, in the order it
328
+ // applies them: an entry it skips cannot shadow anything.
329
+ if (!canEscalate(prior))
330
+ continue;
331
+ if (prior.category !== lesson.category)
332
+ continue;
333
+ if (coversEntirely(prior.claimPath, lesson.claimPath))
334
+ shadowedBy.push(prior.lessonId);
335
+ else if (globsOverlap(prior.claimPath, lesson.claimPath))
336
+ overlapsWith.push(prior.lessonId);
337
+ }
338
+ }
339
+ const tally = tallies.get(lesson.lessonId) ?? {
340
+ opportunities: 0,
341
+ firings: 0,
342
+ outmatchedBy: new Set(),
343
+ };
344
+ const escalates = canEscalate(lesson);
345
+ const liveness = livenessFor({
346
+ escalates,
347
+ shadowedBy,
348
+ firings: tally.firings,
349
+ opportunities: tally.opportunities,
350
+ });
351
+ const contradicts = contradictsBy.get(lesson.lessonId) ?? [];
352
+ const recommendation = recommendationFor(liveness, contradicts);
353
+ counts[recommendation] += 1;
354
+ const partial = {
355
+ lessonId: lesson.lessonId,
356
+ scope: lesson.scope,
357
+ category: lesson.category,
358
+ claimPath: lesson.claimPath,
359
+ escalates,
360
+ opportunities: tally.opportunities,
361
+ firings: tally.firings,
362
+ liveness,
363
+ shadowedBy,
364
+ overlapsWith,
365
+ outmatchedBy: [...tally.outmatchedBy].sort(),
366
+ contradicts,
367
+ recommendation,
368
+ };
369
+ return { ...partial, detail: entryDetail(partial) };
370
+ });
371
+ const reach = lessonReach(lessons);
372
+ // Corpus defects first, and they outrank every hole: shadowing, contradiction
373
+ // and a duplicated id are all properties of the text, provable with no log at
374
+ // all, so a record too thin to measure liveness cannot soften them. Only once
375
+ // the text is clean does the quality of the record decide the verdict.
376
+ const structurallyDefective = contradictions.length > 0 || duplicateIds.length > 0 || counts.retire > 0;
377
+ const status = structurallyDefective
378
+ ? 'defective'
379
+ : counts.rescope > 0
380
+ ? 'defective'
381
+ : reach.escalating === 0 ||
382
+ intakesWithoutLessonIds > 0 ||
383
+ decisionsWithoutContext > 0 ||
384
+ entries.length === 0
385
+ ? 'unverifiable'
386
+ : 'clean';
387
+ const partial = {
388
+ sessionId: options.sessionId,
389
+ reach,
390
+ entries,
391
+ duplicateIds,
392
+ contradictions,
393
+ intakesWithoutLessonIds,
394
+ decisionsWithoutContext,
395
+ counts,
396
+ status,
397
+ };
398
+ return { ...partial, detail: describe(partial), ok: status === 'clean' };
399
+ }