@juzser/blacksmith 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,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
|
+
}
|