@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,238 @@
1
+ import { UNOBSERVED_HINT } from './waveConcurrency.js';
2
+ /**
3
+ * Does this factory build in parallel, or has every epic it closed been narrow?
4
+ *
5
+ * The wave commands each answer half a question and all four share a boundary:
6
+ * `wave schedule` says how wide a plan could run, `wave check` admits a wave,
7
+ * `wave audit` reads one log back to see whether the admission was honoured,
8
+ * and `epic close` writes that reading permanently into `epic-closed`. Every
9
+ * one of them is scoped to the session the operator happens to be standing in.
10
+ * The claim this repo actually makes — that a project here is built by many
11
+ * agents working a plan's tasks at the same time — is a claim about the
12
+ * workshop, and nothing could be asked it.
13
+ *
14
+ * So this folds closes rather than waves. Two reasons, and the second is the
15
+ * one that matters:
16
+ *
17
+ * 1. A close outlives its lineage. The `wave-admitted` and `dispatch_decision`
18
+ * events behind it sit in whichever session ran the build, and an operator
19
+ * asking about the factory is not standing in any of them. The width was
20
+ * copied into the close precisely so it would still be there afterwards.
21
+ *
22
+ * 2. Re-deriving cannot see a close that measured nothing. `auditWaveConcurrency`
23
+ * reports the waves that exist; it has no way to report an epic whose close
24
+ * carried no width at all — an older close from before the field, or one
25
+ * whose record could not be read. Those closes are the honest answer to
26
+ * "how much of this do you actually know", and a summary that silently
27
+ * omitted them would report a factory of three measured epics exactly as
28
+ * confidently as a factory of three hundred.
29
+ */
30
+ const CLOSED_EVENT_TYPE = 'epic-closed';
31
+ /** The verdicts an epic can be graded on, widest first — see {@link gradeOf}. */
32
+ const BEST_FIRST = [
33
+ 'parallel',
34
+ 'partial',
35
+ 'serialized',
36
+ 'single',
37
+ 'unobserved',
38
+ ];
39
+ const ALL_VERDICTS = [
40
+ ...BEST_FIRST,
41
+ 'unwaved',
42
+ 'unmeasured',
43
+ 'unreadable',
44
+ ];
45
+ /**
46
+ * Said when no close carried a width anybody could read.
47
+ *
48
+ * The distinction this hint exists to protect is the one an exit code cannot
49
+ * carry: "every epic closed narrow" and "no epic was ever measured" are
50
+ * opposite states of knowledge, and a factory that has never measured itself
51
+ * must not be able to read as a healthy one.
52
+ */
53
+ export const UNMEASURED_HINT = 'No close read here carried a width. Either these epics were closed before `smith epic close` ' +
54
+ 'recorded one, or the closes were written by hand — close a current epic with `smith epic ' +
55
+ 'close`, or read a live log back with `smith wave audit --session <id>`.';
56
+ /** Nothing was recorded, or nothing readable was. */
57
+ function blank(verdict, problem) {
58
+ return {
59
+ verdict,
60
+ waves: 0,
61
+ byVerdict: Object.fromEntries(BEST_FIRST.map((v) => [v, 0])),
62
+ widest: { declared: 0, observed: 0 },
63
+ unobserved: [],
64
+ problem,
65
+ };
66
+ }
67
+ function countsFrom(raw) {
68
+ if (typeof raw !== 'object' || raw === null)
69
+ return null;
70
+ const source = raw;
71
+ const counts = {};
72
+ for (const verdict of BEST_FIRST) {
73
+ const n = source[verdict];
74
+ // A missing key is not read as zero. The close projects all five even at
75
+ // zero (D-126), so an absent one means this is not a width record at all,
76
+ // and defaulting it would turn a malformed payload into a confident count.
77
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0)
78
+ return null;
79
+ counts[verdict] = n;
80
+ }
81
+ return counts;
82
+ }
83
+ function widthFrom(raw) {
84
+ if (typeof raw !== 'object' || raw === null)
85
+ return null;
86
+ const { declared, observed } = raw;
87
+ if (typeof declared !== 'number' || typeof observed !== 'number')
88
+ return null;
89
+ return { declared, observed };
90
+ }
91
+ /**
92
+ * The epic's verdict is the BEST any of its waves reached, not the worst.
93
+ *
94
+ * The question this answers is whether the epic ever ran wide. An epic that
95
+ * ran three waves in parallel and one serially is the factory working, and
96
+ * grading it by its narrowest wave would report every real build as a failure
97
+ * — which is how a signal becomes a constant and then becomes noise. The
98
+ * serial wave is not thereby hidden: `EpicWidthSummary.serialized` names the
99
+ * epic and the exit code still fails on it. The verdict summarises the epic;
100
+ * it does not speak for every wave inside it.
101
+ */
102
+ function gradeOf(counts, waves) {
103
+ if (waves === 0)
104
+ return 'unwaved';
105
+ for (const verdict of BEST_FIRST)
106
+ if (counts[verdict] > 0)
107
+ return verdict;
108
+ // Unreachable: every wave carries one of the five. Reported rather than
109
+ // guessed, because a count that adds up to nothing is a malformed record.
110
+ return 'unwaved';
111
+ }
112
+ /** Read one close's recorded width, or say why it could not be read. */
113
+ function widthOf(payload) {
114
+ const summary = payload.summary;
115
+ const raw = typeof summary === 'object' && summary !== null
116
+ ? summary.concurrency
117
+ : undefined;
118
+ // Absent and explicitly null are the same answer — nobody measured — and
119
+ // they are BOTH different from a record of zero waves. An older close
120
+ // predating the field has no key; a close that measured and found no wave
121
+ // projects `waves: 0`. Collapsing those would let the factory's unmeasured
122
+ // history read as a history of epics that simply never cut a wave.
123
+ if (raw === undefined || raw === null)
124
+ return blank('unmeasured', null);
125
+ if (typeof raw !== 'object') {
126
+ return blank('unreadable', `epic-width.malformed-record: concurrency is not an object`);
127
+ }
128
+ const record = raw;
129
+ const counts = countsFrom(record.verdicts);
130
+ const widest = widthFrom(record.widest);
131
+ if (typeof record.waves !== 'number' || counts === null || widest === null) {
132
+ return blank('unreadable', 'epic-width.malformed-record: the recorded concurrency has no readable waves, verdicts or widest');
133
+ }
134
+ // The close's own refusal, carried through rather than re-judged. `epic
135
+ // close` catches an unreadable wave record so the close is not lost over it
136
+ // (D-21); reading that as a measured zero here would undo exactly that care.
137
+ const problem = typeof record.problem === 'string' ? record.problem : null;
138
+ return {
139
+ verdict: problem !== null ? 'unreadable' : gradeOf(counts, record.waves),
140
+ waves: record.waves,
141
+ byVerdict: counts,
142
+ widest,
143
+ unobserved: Array.isArray(record.unobserved)
144
+ ? record.unobserved.filter((id) => typeof id === 'string')
145
+ : [],
146
+ problem,
147
+ };
148
+ }
149
+ function str(value) {
150
+ return typeof value === 'string' ? value : null;
151
+ }
152
+ /**
153
+ * Fold every `epic-closed` in these events into one closed epic each.
154
+ *
155
+ * Keyed on the payload's `epic_id` and never on `task_id`, and last close
156
+ * wins — both rules `foldEpics` already keeps, for the reasons it documents:
157
+ * the dogfood close used an unreserved `<epic>/epic` task id that would mint a
158
+ * phantom epic here, and re-closing an epic is a correction rather than a
159
+ * second epic.
160
+ */
161
+ export function readClosedEpicWidths(events) {
162
+ const byId = new Map();
163
+ for (const { event_id, record } of events) {
164
+ if (record.event_type !== CLOSED_EVENT_TYPE)
165
+ continue;
166
+ const payload = record.payload;
167
+ const epicId = str(payload.epic_id);
168
+ // No epic id means no epic to speak for. Inferring one is the phantom-card
169
+ // inference D-43 exists to prevent.
170
+ if (epicId === null)
171
+ continue;
172
+ byId.set(epicId, {
173
+ epicId,
174
+ eventId: event_id,
175
+ closedAt: record.ts,
176
+ sessionId: record.session_id,
177
+ project: record.project ?? null,
178
+ closedBy: str(payload.closed_by) ?? 'unspecified',
179
+ machineVerdict: str(payload.machine_verdict),
180
+ ...widthOf(payload),
181
+ });
182
+ }
183
+ return [...byId.values()];
184
+ }
185
+ /**
186
+ * Score the factory.
187
+ *
188
+ * Exit 1 is a closed epic whose record holds a `serialized` wave — admitted
189
+ * wider than one task and never running two at once. It is the same fact
190
+ * `wave audit` exits 1 on, deliberately: an operator who learned that code has
191
+ * learned this one, and a second rule for the same failure is a second answer
192
+ * waiting to disagree.
193
+ *
194
+ * What is NOT exit 1, and the discipline this depends on: a narrow epic. An
195
+ * epic whose tasks genuinely depend on one another has nothing to run side by
196
+ * side, and `unwaved` and `single` are not faults. Failing on those would fire
197
+ * on every honest serial build in the repo, and a code that fires on
198
+ * everything is routed to /dev/null inside a week — taking the serialized one
199
+ * with it.
200
+ *
201
+ * Exit 2 is the `judge escalations` shape: nothing was judged. Either an
202
+ * admitted wave has no work behind it — a declaration with nothing under it,
203
+ * which epic.ts already tells the judge is the refutable case — or no close
204
+ * carried a width at all, which is the state an empty factory is in and must
205
+ * never be able to report as a healthy one.
206
+ */
207
+ export function summariseEpicWidth(events) {
208
+ const epics = readClosedEpicWidths(events).sort((a, b) => a.closedAt === b.closedAt
209
+ ? a.epicId.localeCompare(b.epicId)
210
+ : b.closedAt.localeCompare(a.closedAt));
211
+ const verdicts = Object.fromEntries(ALL_VERDICTS.map((v) => [v, 0]));
212
+ for (const epic of epics)
213
+ verdicts[epic.verdict] += 1;
214
+ const serialized = epics.filter((e) => e.byVerdict.serialized > 0).map((e) => e.epicId);
215
+ const unobserved = epics
216
+ .filter((e) => e.byVerdict.unobserved > 0 || e.unobserved.length > 0)
217
+ .map((e) => e.epicId);
218
+ const unmeasured = epics
219
+ .filter((e) => e.verdict === 'unmeasured' || e.verdict === 'unreadable')
220
+ .map((e) => e.epicId);
221
+ // "Judged" means a width was read and there was a wave in it. An epic that
222
+ // measured zero waves is measured and is not evidence either way, so it
223
+ // cannot on its own turn an unmeasured factory into a passing one.
224
+ const judged = epics.some((e) => e.problem === null && e.waves > 0);
225
+ return {
226
+ epics,
227
+ verdicts,
228
+ widest: {
229
+ declared: epics.reduce((max, e) => Math.max(max, e.widest.declared), 0),
230
+ observed: epics.reduce((max, e) => Math.max(max, e.widest.observed), 0),
231
+ },
232
+ serialized,
233
+ unobserved,
234
+ unmeasured,
235
+ hint: unobserved.length > 0 ? UNOBSERVED_HINT : judged ? '' : UNMEASURED_HINT,
236
+ exitCode: serialized.length > 0 ? 1 : unobserved.length > 0 || !judged ? 2 : 0,
237
+ };
238
+ }
@@ -0,0 +1,249 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** A `Record` over every `GateBlockedReason`, not a switch with a default: a reason missing from `GateBlockedReason` fails to compile here. */
3
+ const GATE_BLOCKED_ERROR_CLASSES = {
4
+ 'schema-invalid': 'gate.blocked.schema-invalid',
5
+ 'artifacts-missing': 'gate.blocked.artifacts-missing',
6
+ 'not-committed': 'gate.blocked.not-committed',
7
+ 'deps-missing': 'gate.blocked.deps-missing',
8
+ 'judges-outstanding': 'gate.blocked.judges-outstanding',
9
+ 'grader-invalid': 'gate.blocked.grader-invalid',
10
+ 'grader-fail': 'gate.blocked.grader-fail',
11
+ 'tests-failed': 'gate.blocked.tests-failed',
12
+ 'coverage-evidence': 'gate.blocked.coverage-evidence',
13
+ findings: 'gate.blocked.findings',
14
+ };
15
+ /** Exported for the table-driven test: the ten reasons, read off the table above rather than hand-copied a second time. */
16
+ export const GATE_BLOCKED_REASONS = Object.keys(GATE_BLOCKED_ERROR_CLASSES);
17
+ /** A blocked gate and a failed task carry no payload-level severity; both block a task outright, matching taxonomy.yml's `S2-major`. */
18
+ const BLOCKING_SEVERITY = 'S2-major';
19
+ const GATE_OUTCOME_EVENT_TYPE = 'gate-outcome';
20
+ const ERROR_LOGGED_EVENT_TYPE = 'error-logged';
21
+ const TASK_ADDED_EVENT_TYPE = 'task-added';
22
+ /**
23
+ * Source 2: `task_status: failed` is set in exactly one place in the whole
24
+ * projector, `db/projector.ts`'s `case 'task-added'` arm copying
25
+ * `p.task_status` verbatim -- every other case assigns a hardcoded literal,
26
+ * never a payload-derived value. So this source is always a `task-added`
27
+ * event whose own payload already declares `task_status: 'failed'`, and
28
+ * carries no failure discriminator beyond that.
29
+ */
30
+ const TASK_FAILED_ERROR_CLASS = 'task.failed';
31
+ /** Default project for an event stamped with none (events.ts's documented convention; the writer never sets it, only read helpers default it). */
32
+ const DEFAULT_PROJECT = 'black-smith';
33
+ function asString(value) {
34
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
35
+ }
36
+ function asNumber(value) {
37
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
38
+ }
39
+ /**
40
+ * One event to a `Candidate`, `'ignore'` when it is not one of the three
41
+ * sources at all, or `null` when it matches a source's shape but is missing
42
+ * a required field (malformed, counted by the caller as `skipped`).
43
+ */
44
+ function toCandidate(event) {
45
+ const { record } = event;
46
+ const payload = record.payload ?? {};
47
+ const project = asString(record.project) ?? DEFAULT_PROJECT;
48
+ const sessionId = asString(record.session_id);
49
+ const planVersion = asNumber(record.plan_version) ?? null;
50
+ const ts = asString(record.ts);
51
+ if (record.event_type === GATE_OUTCOME_EVENT_TYPE) {
52
+ if (payload.outcome !== 'blocked')
53
+ return 'ignore';
54
+ const reasonRaw = asString(payload.reason);
55
+ const taskRef = asString(record.task_id);
56
+ if (!sessionId || !ts || !taskRef || !reasonRaw)
57
+ return null;
58
+ if (!(reasonRaw in GATE_BLOCKED_ERROR_CLASSES))
59
+ return null;
60
+ const reason = reasonRaw;
61
+ return {
62
+ project,
63
+ source: 'gate-outcome',
64
+ errorClass: GATE_BLOCKED_ERROR_CLASSES[reason],
65
+ taskRef,
66
+ severity: BLOCKING_SEVERITY,
67
+ eventId: event.event_id,
68
+ ts,
69
+ sessionId,
70
+ epicId: null,
71
+ planVersion,
72
+ };
73
+ }
74
+ if (record.event_type === ERROR_LOGGED_EVENT_TYPE) {
75
+ const errorClass = asString(payload.error);
76
+ const severity = asString(payload.severity);
77
+ const taskRef = asString(record.task_id) ?? asString(payload.task_ref);
78
+ if (!sessionId || !ts || !taskRef || !errorClass || !severity)
79
+ return null;
80
+ return {
81
+ project,
82
+ source: 'error-logged',
83
+ errorClass,
84
+ taskRef,
85
+ severity,
86
+ eventId: event.event_id,
87
+ ts,
88
+ sessionId,
89
+ epicId: null,
90
+ planVersion,
91
+ };
92
+ }
93
+ if (record.event_type === TASK_ADDED_EVENT_TYPE) {
94
+ if (payload.task_status !== 'failed')
95
+ return 'ignore';
96
+ const taskRef = asString(record.task_id);
97
+ if (!sessionId || !ts || !taskRef)
98
+ return null;
99
+ const epicId = asString(payload.epic_id) ?? null;
100
+ return {
101
+ project,
102
+ source: 'task-failed',
103
+ errorClass: TASK_FAILED_ERROR_CLASS,
104
+ taskRef,
105
+ severity: BLOCKING_SEVERITY,
106
+ eventId: event.event_id,
107
+ ts,
108
+ sessionId,
109
+ epicId,
110
+ planVersion,
111
+ };
112
+ }
113
+ return 'ignore';
114
+ }
115
+ /**
116
+ * The first 16 hex characters of a SHA-256 over the NUL-joined tuple
117
+ * `project`, `source`, `error_class`, `task_ref`. Deliberately excludes
118
+ * session id, epic id, plan version, event id and timestamp -- those are
119
+ * exactly the fields that change across repeated rounds of one broken gate,
120
+ * and including any of them would open one issue per round instead of one.
121
+ */
122
+ function computeFingerprint(project, source, errorClass, taskRef) {
123
+ const material = [project, source, errorClass, taskRef].join('\0');
124
+ return createHash('sha256').update(material).digest('hex').slice(0, 16);
125
+ }
126
+ /**
127
+ * Fold a stored event sequence into reportable errors. Pure: two calls with
128
+ * identical input return byte-identical output. `now` is threaded in rather
129
+ * than read off the clock; nothing in this contract branches on it yet.
130
+ */
131
+ export function foldErrorEvents(events, now, isProjectEnabled) {
132
+ void now;
133
+ const candidates = [];
134
+ let skipped = 0;
135
+ for (const event of events) {
136
+ const outcome = toCandidate(event);
137
+ if (outcome === 'ignore')
138
+ continue;
139
+ if (outcome === null) {
140
+ skipped += 1;
141
+ continue;
142
+ }
143
+ if (!isProjectEnabled(outcome.project))
144
+ continue;
145
+ candidates.push(outcome);
146
+ }
147
+ const groups = new Map();
148
+ for (const candidate of candidates) {
149
+ const fingerprint = computeFingerprint(candidate.project, candidate.source, candidate.errorClass, candidate.taskRef);
150
+ const group = groups.get(fingerprint);
151
+ if (group)
152
+ group.push(candidate);
153
+ else
154
+ groups.set(fingerprint, [candidate]);
155
+ }
156
+ const reports = [];
157
+ for (const [fingerprint, group] of groups) {
158
+ const latest = group.reduce((a, b) => b.ts > a.ts || (b.ts === a.ts && b.eventId > a.eventId) ? b : a);
159
+ for (const candidate of group) {
160
+ reports.push({
161
+ fingerprint,
162
+ project: candidate.project,
163
+ source: candidate.source,
164
+ error_class: candidate.errorClass,
165
+ task_ref: candidate.taskRef,
166
+ severity: latest.severity,
167
+ latest_event_id: latest.eventId,
168
+ timestamp: latest.ts,
169
+ session_id: latest.sessionId,
170
+ epic_id: latest.epicId,
171
+ plan_version: latest.planVersion,
172
+ });
173
+ }
174
+ }
175
+ return { reports, skipped };
176
+ }
177
+ // Rendering -- metadata and pointers only, safe by construction. Both
178
+ // renderers take the same shape of typed record, built from an ErrorReport,
179
+ // never a raw source event: neither signature has a free-text field.
180
+ /** The fixed literal format of the machine-readable fingerprint line, exported so the dedup search and the renderer read the same constant. */
181
+ export const FINGERPRINT_LINE_PREFIX = 'Fingerprint: ';
182
+ function fingerprintLine(fingerprint) {
183
+ return `${FINGERPRINT_LINE_PREFIX}${fingerprint}`;
184
+ }
185
+ /** The local, read-only command an operator runs to see the detail this body/comment deliberately omit. */
186
+ function smithEventCommand(sessionId) {
187
+ return `smith event tail ${sessionId} --lineage`;
188
+ }
189
+ /** Builds a body-renderer input record from a folded report -- the record's key set is the allowlist; nothing else can reach `renderBody`. */
190
+ export function toIssueBodyFields(report) {
191
+ return {
192
+ latest_event_id: report.latest_event_id,
193
+ task_ref: report.task_ref,
194
+ error_class: report.error_class,
195
+ severity: report.severity,
196
+ session_id: report.session_id,
197
+ epic_id: report.epic_id,
198
+ plan_version: report.plan_version,
199
+ project: report.project,
200
+ source: report.source,
201
+ fingerprint: report.fingerprint,
202
+ };
203
+ }
204
+ /** Builds a comment-renderer input record from a folded report -- same allowlist-by-construction guarantee, same mechanism. */
205
+ export function toIssueCommentFields(report) {
206
+ return {
207
+ latest_event_id: report.latest_event_id,
208
+ timestamp: report.timestamp,
209
+ session_id: report.session_id,
210
+ epic_id: report.epic_id,
211
+ plan_version: report.plan_version,
212
+ fingerprint: report.fingerprint,
213
+ };
214
+ }
215
+ /** The issue title: stable, human-first, derived only from allowlisted fields. */
216
+ export function renderTitle(fields) {
217
+ return `${fields.task_ref}: ${fields.error_class}`;
218
+ }
219
+ /** Issue body -- metadata and pointers only, opened once per fingerprint. */
220
+ export function renderBody(fields) {
221
+ return [
222
+ `Task: ${fields.task_ref}`,
223
+ `Error class: ${fields.error_class}`,
224
+ `Severity: ${fields.severity}`,
225
+ `Session: ${fields.session_id}`,
226
+ `Epic: ${fields.epic_id ?? '(none)'}`,
227
+ `Plan version: ${fields.plan_version ?? '(unknown)'}`,
228
+ `Project: ${fields.project}`,
229
+ `Source: ${fields.source}`,
230
+ fingerprintLine(fields.fingerprint),
231
+ '',
232
+ 'Read the detail locally:',
233
+ `\`${smithEventCommand(fields.session_id)}\``,
234
+ ].join('\n');
235
+ }
236
+ /** Issue comment -- the higher-volume write path (five rounds of one broken gate write one body and four comments). Same allowlist construction. */
237
+ export function renderComment(fields) {
238
+ return [
239
+ `New occurrence: ${fields.latest_event_id}`,
240
+ `Timestamp: ${fields.timestamp}`,
241
+ `Session: ${fields.session_id}`,
242
+ `Epic: ${fields.epic_id ?? '(none)'}`,
243
+ `Plan version: ${fields.plan_version ?? '(unknown)'}`,
244
+ fingerprintLine(fields.fingerprint),
245
+ '',
246
+ 'Read the detail locally:',
247
+ `\`${smithEventCommand(fields.session_id)}\``,
248
+ ].join('\n');
249
+ }
@@ -0,0 +1,11 @@
1
+ /** Shared typed-error base for every orchestrator module (one pattern, reused). */
2
+ export class SmithError extends Error {
3
+ code;
4
+ details;
5
+ constructor(code, message, details = {}) {
6
+ super(message);
7
+ this.name = new.target.name;
8
+ this.code = code;
9
+ this.details = Object.freeze({ ...details });
10
+ }
11
+ }