@juzser/blacksmith 0.2.0 → 0.4.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 (71) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. package/package.json +8 -8
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { isLaterEvent } from './eventOrder.js';
2
3
  /** A `Record` over every `GateBlockedReason`, not a switch with a default: a reason missing from `GateBlockedReason` fails to compile here. */
3
4
  const GATE_BLOCKED_ERROR_CLASSES = {
4
5
  'schema-invalid': 'gate.blocked.schema-invalid',
@@ -19,6 +20,10 @@ const BLOCKING_SEVERITY = 'S2-major';
19
20
  const GATE_OUTCOME_EVENT_TYPE = 'gate-outcome';
20
21
  const ERROR_LOGGED_EVENT_TYPE = 'error-logged';
21
22
  const TASK_ADDED_EVENT_TYPE = 'task-added';
23
+ /** `agents-registry.ts`'s `DISPATCH_EVENT_TYPE`, hand-restated rather than
24
+ * imported -- this module's only imports are `node:crypto` and two local,
25
+ * type-only siblings, on purpose (see the file banner). */
26
+ const DISPATCH_DECISION_EVENT_TYPE = 'dispatch_decision';
22
27
  /**
23
28
  * Source 2: `task_status: failed` is set in exactly one place in the whole
24
29
  * projector, `db/projector.ts`'s `case 'task-added'` arm copying
@@ -28,8 +33,6 @@ const TASK_ADDED_EVENT_TYPE = 'task-added';
28
33
  * carries no failure discriminator beyond that.
29
34
  */
30
35
  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
36
  function asString(value) {
34
37
  return typeof value === 'string' && value.length > 0 ? value : undefined;
35
38
  }
@@ -37,18 +40,14 @@ function asNumber(value) {
37
40
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
38
41
  }
39
42
  /**
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
+ * The sources, keyed by the event type each one reads. A table rather than a
44
+ * chain of `if`s so that ISSUE_CANDIDATE_EVENT_TYPES below can be read off
45
+ * it: a fourth source cannot be folded without appearing here, and appearing
46
+ * here is what puts it in the roster.
43
47
  */
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) {
48
+ const CANDIDATE_READERS = Object.freeze({
49
+ [GATE_OUTCOME_EVENT_TYPE]: (event, { payload, project, sessionId, planVersion, ts }) => {
50
+ const { record } = event;
52
51
  if (payload.outcome !== 'blocked')
53
52
  return 'ignore';
54
53
  const reasonRaw = asString(payload.reason);
@@ -70,8 +69,9 @@ function toCandidate(event) {
70
69
  epicId: null,
71
70
  planVersion,
72
71
  };
73
- }
74
- if (record.event_type === ERROR_LOGGED_EVENT_TYPE) {
72
+ },
73
+ [ERROR_LOGGED_EVENT_TYPE]: (event, { payload, project, sessionId, planVersion, ts }) => {
74
+ const { record } = event;
75
75
  const errorClass = asString(payload.error);
76
76
  const severity = asString(payload.severity);
77
77
  const taskRef = asString(record.task_id) ?? asString(payload.task_ref);
@@ -89,8 +89,9 @@ function toCandidate(event) {
89
89
  epicId: null,
90
90
  planVersion,
91
91
  };
92
- }
93
- if (record.event_type === TASK_ADDED_EVENT_TYPE) {
92
+ },
93
+ [TASK_ADDED_EVENT_TYPE]: (event, { payload, project, sessionId, planVersion, ts }) => {
94
+ const { record } = event;
94
95
  if (payload.task_status !== 'failed')
95
96
  return 'ignore';
96
97
  const taskRef = asString(record.task_id);
@@ -109,9 +110,168 @@ function toCandidate(event) {
109
110
  epicId,
110
111
  planVersion,
111
112
  };
113
+ },
114
+ });
115
+ /**
116
+ * The event types this fold reads candidates from — read off the table above
117
+ * rather than hand-copied a second time, the way GATE_BLOCKED_REASONS is.
118
+ * cli.ts scopes `--epic`/`--since` with it and passes everything else
119
+ * through as history; typed there instead, a source added here and not there
120
+ * would be reported outside the window the operator asked about.
121
+ */
122
+ export const ISSUE_CANDIDATE_EVENT_TYPES = new Set(Object.keys(CANDIDATE_READERS));
123
+ /** The row's task ref, off the envelope first and the payload second -- the same two places every `CandidateReader` above already looks. */
124
+ function candidateTaskRef(record) {
125
+ const payload = (record.payload ?? {});
126
+ return asString(record.task_id) ?? asString(payload.task_ref);
127
+ }
128
+ /**
129
+ * Event types allowed to stamp a session's project for
130
+ * `sessionProjectStamps` below: the issue candidates themselves, plus
131
+ * `task-added` and `dispatch_decision` rows recording where a task was
132
+ * planned or dispatched. Deliberately excludes `issue-reported` and every
133
+ * other reporter-authored row -- those carry this module's own *guess* at a
134
+ * row's project (see `appendIssueReported` in `issueReporter.ts`), not an
135
+ * independent origin stamp, so folding them back in here would let one run's
136
+ * guess leak into the next run's resolution of an unrelated, unstamped row
137
+ * in the same session.
138
+ */
139
+ const ORIGIN_STAMP_EVENT_TYPES = new Set([
140
+ ...ISSUE_CANDIDATE_EVENT_TYPES,
141
+ DISPATCH_DECISION_EVENT_TYPE,
142
+ ]);
143
+ /**
144
+ * The session's project, from its origin-stamped rows only -- and only when
145
+ * every one of them agrees. Real sessions mix dozens of stamped rows with
146
+ * unstamped ones (evidence: a foreign project's session stamps some events
147
+ * and not others); a unanimous stamped sibling in the same session is strong
148
+ * evidence for an unstamped one. A session whose origin stamps disagree
149
+ * tells us nothing safe to guess, so it answers unresolved rather than
150
+ * picking whichever stamp happened to come first.
151
+ */
152
+ function sessionProjectStamps(events) {
153
+ const bySession = new Map();
154
+ for (const { record } of events) {
155
+ if (!ORIGIN_STAMP_EVENT_TYPES.has(record.event_type))
156
+ continue;
157
+ const project = asString(record.project);
158
+ if (project === undefined)
159
+ continue;
160
+ const seen = bySession.get(record.session_id);
161
+ if (seen)
162
+ seen.add(project);
163
+ else
164
+ bySession.set(record.session_id, new Set([project]));
112
165
  }
113
- return 'ignore';
166
+ const stamps = new Map();
167
+ for (const [sessionId, projects] of bySession) {
168
+ if (projects.size !== 1)
169
+ continue;
170
+ const [project] = projects;
171
+ if (project !== undefined)
172
+ stamps.set(sessionId, project);
173
+ }
174
+ return stamps;
114
175
  }
176
+ function taskAddedKey(sessionId, taskId) {
177
+ return `${sessionId}\u0000${taskId}`;
178
+ }
179
+ /** `task-added`'s own `payload.epic_id`, keyed by session and task id, so a bare ref (no epic segment) can still be widened to `<epic>/<ref>` before asking the resolver. */
180
+ function taskAddedEpicIds(events) {
181
+ const out = new Map();
182
+ for (const { record } of events) {
183
+ if (record.event_type !== TASK_ADDED_EVENT_TYPE)
184
+ continue;
185
+ const taskId = asString(record.task_id);
186
+ const epicId = asString(record.payload?.epic_id);
187
+ if (taskId !== undefined && epicId !== undefined) {
188
+ out.set(taskAddedKey(record.session_id, taskId), epicId);
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+ /**
194
+ * Widens a per-epic `ResolveProjectForTaskRef` into a `RowProjectResolver`
195
+ * with three session-scoped fallbacks a single task ref cannot answer alone,
196
+ * tried strictly in this order (S2-c):
197
+ *
198
+ * 1. `resolveProject`, the strict/direct resolver -- a bare ref (no epic
199
+ * segment) tried again as `<epic>/<ref>` using the same session's own
200
+ * `task-added` row when the bare ref alone cannot answer.
201
+ * 2. The session stamp: the ONE project every origin-stamped row in the same
202
+ * session agrees on (`sessionProjectStamps`), when unanimous.
203
+ * 3. `selfFallback`, tried last and only when neither of the above could
204
+ * answer -- same widening as step 1.
205
+ *
206
+ * Self-fallback sits last on purpose: it is "no plan says otherwise, and a
207
+ * plan exists locally for this epic, so guess this factory's own name" --
208
+ * the weakest of the three signals. A session's OWN unanimous stamp naming a
209
+ * DIFFERENT project is stronger evidence than that guess, and must not be
210
+ * overridden by it just because the same session also has an unrelated,
211
+ * unstamped row whose epic happens to have a local, unlabeled plan. Before
212
+ * this order existed, the self-fallback was folded into `resolveProject`
213
+ * itself (step 1) and so always outran the session stamp.
214
+ *
215
+ * Exported so `issueReporter.ts`'s `priorOpenReports` can apply the identical
216
+ * fallback to the same event snapshot.
217
+ */
218
+ export function withSessionFallback(events, resolveProject, selfFallback = () => null) {
219
+ const stamps = sessionProjectStamps(events);
220
+ const epicIds = taskAddedEpicIds(events);
221
+ const tryResolve = (resolve, taskRef, sessionId) => {
222
+ if (taskRef === undefined)
223
+ return null;
224
+ const direct = resolve(taskRef);
225
+ if (direct !== null)
226
+ return direct;
227
+ if (sessionId === undefined)
228
+ return null;
229
+ const epicId = epicIds.get(taskAddedKey(sessionId, taskRef));
230
+ if (epicId === undefined)
231
+ return null;
232
+ return resolve(`${epicId}/${taskRef}`);
233
+ };
234
+ return (taskRef, sessionId) => {
235
+ const direct = tryResolve(resolveProject, taskRef, sessionId);
236
+ if (direct !== null)
237
+ return direct;
238
+ if (sessionId !== undefined) {
239
+ const stamp = stamps.get(sessionId);
240
+ if (stamp !== undefined)
241
+ return stamp;
242
+ }
243
+ return tryResolve(selfFallback, taskRef, sessionId);
244
+ };
245
+ }
246
+ /** One event to a `Candidate`, or `'ignore'` when no source claims its event type at all. */
247
+ function toCandidate(event, resolveRow) {
248
+ const { record } = event;
249
+ const read = CANDIDATE_READERS[record.event_type];
250
+ if (!read)
251
+ return 'ignore';
252
+ const stamped = asString(record.project);
253
+ const taskRef = candidateTaskRef(record);
254
+ const sessionId = asString(record.session_id);
255
+ const project = stamped ?? resolveRow(taskRef, sessionId);
256
+ return read(event, {
257
+ payload: record.payload ?? {},
258
+ project,
259
+ sessionId,
260
+ planVersion: asNumber(record.plan_version) ?? null,
261
+ ts: asString(record.ts),
262
+ });
263
+ }
264
+ /**
265
+ * A row whose project cannot be resolved at all still needs a stable
266
+ * fingerprint-grouping key -- otherwise two unrelated foreign projects'
267
+ * rows sharing `(source, error_class, task_ref)` would incorrectly group
268
+ * together and leak one row's session/epic/severity metadata onto the
269
+ * other's report, inside this factory's own private log (both rows are
270
+ * always skipped before anything is filed publicly, but the log itself
271
+ * should stay correct). `task_ref` alone already disambiguates in practice
272
+ * (refs are namespaced by epic id), so a single fixed sentinel is enough.
273
+ */
274
+ const UNRESOLVED_PROJECT_FINGERPRINT_KEY = '\u0000unresolved-project\u0000';
115
275
  /**
116
276
  * The first 16 hex characters of a SHA-256 over the NUL-joined tuple
117
277
  * `project`, `source`, `error_class`, `task_ref`. Deliberately excludes
@@ -120,27 +280,52 @@ function toCandidate(event) {
120
280
  * and including any of them would open one issue per round instead of one.
121
281
  */
122
282
  function computeFingerprint(project, source, errorClass, taskRef) {
123
- const material = [project, source, errorClass, taskRef].join('\0');
283
+ const material = [
284
+ project ?? UNRESOLVED_PROJECT_FINGERPRINT_KEY,
285
+ source,
286
+ errorClass,
287
+ taskRef,
288
+ ].join('\0');
124
289
  return createHash('sha256').update(material).digest('hex').slice(0, 16);
125
290
  }
126
291
  /**
127
292
  * Fold a stored event sequence into reportable errors. Pure: two calls with
128
293
  * identical input return byte-identical output. `now` is threaded in rather
129
294
  * than read off the clock; nothing in this contract branches on it yet.
295
+ *
296
+ * `resolveProject` and `selfFallback` both default to "no answer" so the
297
+ * ~50 existing call sites across this module's own tests and
298
+ * `issueReporter.ts`'s need no change. An unstamped row still unresolved
299
+ * after `withSessionFallback`'s three fallbacks reports `project: null`
300
+ * rather than this factory's own name -- `isProjectEnabled` is never asked
301
+ * about a row it cannot identify, and the decision to skip filing it
302
+ * belongs to `issueReporter.ts`, the one caller that goes on to call `gh`.
303
+ *
304
+ * `scopeEvents` defaults to `events` itself -- the session-stamp/`task-added`
305
+ * maps `withSessionFallback` builds read the same rows this fold iterates,
306
+ * same as before this parameter existed. A caller that narrows `events` to a
307
+ * candidate WINDOW (`cli.ts`'s `--epic`/`--since`) passes the full, unscoped
308
+ * lineage here instead (S3), so a session-mate's stamp sitting outside that
309
+ * window still counts as evidence for a row inside it -- scoping which rows
310
+ * get REPORTED must not also scope which rows get CONSULTED, or the same
311
+ * unstamped row resolves to a different project (and hashes a different
312
+ * fingerprint, S1-b) depending on whether the caller happened to pass
313
+ * `--epic`.
130
314
  */
131
- export function foldErrorEvents(events, now, isProjectEnabled) {
315
+ export function foldErrorEvents(events, now, isProjectEnabled, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
132
316
  void now;
317
+ const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
133
318
  const candidates = [];
134
319
  let skipped = 0;
135
320
  for (const event of events) {
136
- const outcome = toCandidate(event);
321
+ const outcome = toCandidate(event, resolveRow);
137
322
  if (outcome === 'ignore')
138
323
  continue;
139
324
  if (outcome === null) {
140
325
  skipped += 1;
141
326
  continue;
142
327
  }
143
- if (!isProjectEnabled(outcome.project))
328
+ if (outcome.project !== null && !isProjectEnabled(outcome.project))
144
329
  continue;
145
330
  candidates.push(outcome);
146
331
  }
@@ -155,7 +340,7 @@ export function foldErrorEvents(events, now, isProjectEnabled) {
155
340
  }
156
341
  const reports = [];
157
342
  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);
343
+ const latest = group.reduce((a, b) => (isLaterEvent(b, a) ? b : a));
159
344
  for (const candidate of group) {
160
345
  reports.push({
161
346
  fingerprint,
@@ -225,7 +410,7 @@ export function renderBody(fields) {
225
410
  `Session: ${fields.session_id}`,
226
411
  `Epic: ${fields.epic_id ?? '(none)'}`,
227
412
  `Plan version: ${fields.plan_version ?? '(unknown)'}`,
228
- `Project: ${fields.project}`,
413
+ `Project: ${fields.project ?? '(unknown)'}`,
229
414
  `Source: ${fields.source}`,
230
415
  fingerprintLine(fields.fingerprint),
231
416
  '',
@@ -0,0 +1,102 @@
1
+ // The log-order vocabulary, split out of events.ts (the log's I/O hub) so a
2
+ // pure reader can order events without pulling in filesystem access.
3
+ // errorIssues.ts is that reader: task-2's purity clauses keep it free of any
4
+ // runtime import from `./events.js`, so this module has NO import statement
5
+ // at all — not even `./errors.js` — and its transitive import graph is
6
+ // empty.
7
+ /**
8
+ * Split an event id into the session that owns it and its line index.
9
+ *
10
+ * Event ids have always been `<session-id>#<index>` — that shape is what makes
11
+ * a cross-session reference resolvable at all (P9-7): given only the id, a
12
+ * reader knows which log file to open. Splitting on the LAST `#` keeps a
13
+ * session id that happens to contain one from silently resolving to the wrong
14
+ * session.
15
+ *
16
+ * This is the ONE parser of that shape. Unlike `parseEventId` in
17
+ * `events.ts` (the throwing wrapper callers reach for at the log's edges),
18
+ * this never throws — it returns `null` for anything malformed, including a
19
+ * non-string reached through a cast, so a pure comparator can stay total.
20
+ */
21
+ export function tryParseEventId(eventId) {
22
+ if (typeof eventId !== 'string')
23
+ return null;
24
+ const cut = eventId.lastIndexOf('#');
25
+ const sessionId = cut === -1 ? '' : eventId.slice(0, cut);
26
+ const rawIndex = eventId.slice(cut + 1);
27
+ // Number.parseInt would accept "3abc"; an event id is exact or it is a typo.
28
+ const index = /^\d+$/.test(rawIndex) ? Number(rawIndex) : Number.NaN;
29
+ if (cut === -1 || sessionId.length === 0 || Number.isNaN(index))
30
+ return null;
31
+ return { sessionId, index };
32
+ }
33
+ /**
34
+ * Where an event sits in the order the log actually wrote it.
35
+ *
36
+ * An event id is `<session-id>#<index>` and the index is the event's line in
37
+ * its session's log, so within a session this *is* the order rather than a
38
+ * proxy for it. Across sessions the logs are separate files that nothing
39
+ * interleaves, so the session id is here to make the answer the same on every
40
+ * call, not because one session precedes another.
41
+ *
42
+ * Total where tryParseEventId returns null, and the fallback is unreachable
43
+ * by construction — every id this sees came out of readEvents() or a
44
+ * projection of it, and readEvents builds each one as `<session>#<index>`. It
45
+ * is here because the readers on top of this are a dashboard `/api/pulse`
46
+ * polls every 5s and two audits the operator runs on a whole log, and an id
47
+ * that somehow would not parse should sort somewhere rather than take the
48
+ * caller down with it. Where it lands is deliberately modest: an event whose
49
+ * place in the log is unreadable does not get to win a tie on it.
50
+ */
51
+ function logOrderOf(eventId) {
52
+ return tryParseEventId(eventId) ?? { sessionId: eventId, index: -1 };
53
+ }
54
+ /**
55
+ * The order the log wrote two events in: negative when `a` came first,
56
+ * positive when `b` did, zero only for the same event.
57
+ *
58
+ * `ts` is stamped at millisecond resolution (`appendEvent` in `events.ts`
59
+ * stamps it), so a burst of appends routinely shares one: the test fixture's
60
+ * own last two events tie in roughly one build in three, and a gate outcome
61
+ * and the retry dispatched in answer to it are written back to back. Nothing
62
+ * else in the record carries the sequence — `events_raw` has no such column
63
+ * and the JSONL line has no such field — so on a tie `ts` has nothing left to
64
+ * say, and nothing downstream of it does either. A `>` between two tied rows
65
+ * is not a decision, it is whichever the scan reached first; `ORDER BY ts` is
66
+ * the same non-answer spelled in SQL, since SQLite promises nothing about
67
+ * tied rows and hands them back in physical order, which changes the moment a
68
+ * row is rewritten; and a JS `.sort()` whose comparator returns 0 throughout
69
+ * is stable, so it keeps that same scan order and passes it off as
70
+ * chronology.
71
+ *
72
+ * The log index behind the event id is what actually decides, and it has to
73
+ * be read as a number: ordered as text — which is what `ORDER BY ts,
74
+ * event_id` does — `#9` sorts after `#10`, so the tiebreaker inverts as soon
75
+ * as a session's log passes ten events.
76
+ *
77
+ * It lives here, below `events.ts`, rather than in any one reader because
78
+ * more than one of them asks this question: the dashboard queries fold and
79
+ * sort rows, escalation.ts walks a task's rounds looking for the dispatch on
80
+ * either side of one, and errorIssues.ts's pure fold needs the same answer
81
+ * without importing the fs hub that owns the log's I/O. Every ordering a
82
+ * reader would call chronological routes through here, so that no two of them
83
+ * can answer the same question about the same two events differently — which
84
+ * is exactly what happened when the callers spelled the comparison
85
+ * themselves, with opposite operators.
86
+ */
87
+ export function compareLogOrder(a, b) {
88
+ if (a.ts !== b.ts)
89
+ return a.ts < b.ts ? -1 : 1;
90
+ const left = logOrderOf(a.eventId);
91
+ const right = logOrderOf(b.eventId);
92
+ if (left.sessionId !== right.sessionId)
93
+ return left.sessionId < right.sessionId ? -1 : 1;
94
+ return left.index - right.index;
95
+ }
96
+ /**
97
+ * Whether `a` is the later of two events — the one a reader means by "what
98
+ * just happened".
99
+ */
100
+ export function isLaterEvent(a, b) {
101
+ return compareLogOrder(a, b) > 0;
102
+ }
@@ -2,6 +2,7 @@ import { existsSync, readdirSync } from 'node:fs';
2
2
  import { appendFile, mkdir, open, readFile, rm, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { SmithError } from './errors.js';
5
+ import { tryParseEventId } from './eventOrder.js';
5
6
  import { STATE_EVENTS_DIR } from './paths.js';
6
7
  import { compileSchemas, validateRecord } from './schemas.js';
7
8
  import { taskIdsMatch } from './taskId.js';
@@ -17,6 +18,11 @@ export class EventError extends SmithError {
17
18
  * the literal is a second thing to keep in step.
18
19
  */
19
20
  export const ROOT_EVENT_TYPE = 'session-start';
21
+ // `compareLogOrder`, `isLaterEvent` and `ParsedEventId` now live in
22
+ // `eventOrder.ts`, a leaf with no import statement, so a pure reader (the
23
+ // error fold in errorIssues.ts) can order events without importing this
24
+ // fs hub.
25
+ export { compareLogOrder, isLaterEvent } from './eventOrder.js';
20
26
  /**
21
27
  * Split an event id into the session that owns it and its line index.
22
28
  *
@@ -25,6 +31,10 @@ export const ROOT_EVENT_TYPE = 'session-start';
25
31
  * reader knows which log file to open. Splitting on the LAST `#` keeps a
26
32
  * session id that happens to contain one from silently resolving to the wrong
27
33
  * session.
34
+ *
35
+ * The parse itself lives in `eventOrder.ts` (`tryParseEventId`, which never
36
+ * throws); this wrapper is the one place that turns a malformed id into the
37
+ * `events.malformed-event-id` error every caller of `parseEventId` expects.
28
38
  */
29
39
  export function parseEventId(eventId) {
30
40
  // Typed as a string and reached with whatever the writer sent. `smith event
@@ -37,87 +47,11 @@ export function parseEventId(eventId) {
37
47
  if (typeof eventId !== 'string') {
38
48
  throw new EventError('events.malformed-event-id', `${JSON.stringify(eventId) ?? String(eventId)} (${typeof eventId}) is not an event id. The form is <session-id>#<index>, e.g. dogfood-envkit-1#42.`, { event_id: eventId });
39
49
  }
40
- const cut = eventId.lastIndexOf('#');
41
- const sessionId = cut === -1 ? '' : eventId.slice(0, cut);
42
- const rawIndex = eventId.slice(cut + 1);
43
- // Number.parseInt would accept "3abc"; an event id is exact or it is a typo.
44
- const index = /^\d+$/.test(rawIndex) ? Number(rawIndex) : Number.NaN;
45
- if (cut === -1 || sessionId.length === 0 || Number.isNaN(index)) {
50
+ const parsed = tryParseEventId(eventId);
51
+ if (parsed === null) {
46
52
  throw new EventError('events.malformed-event-id', `"${eventId}" is not an event id. The form is <session-id>#<index>, e.g. dogfood-envkit-1#42.`, { event_id: eventId });
47
53
  }
48
- return { sessionId, index };
49
- }
50
- /**
51
- * Where an event sits in the order the log actually wrote it.
52
- *
53
- * An event id is `<session-id>#<index>` and the index is the event's line in
54
- * its session's log, so within a session this *is* the order rather than a
55
- * proxy for it. Across sessions the logs are separate files that nothing
56
- * interleaves, so the session id is here to make the answer the same on every
57
- * call, not because one session precedes another.
58
- *
59
- * Total where parseEventId throws, and the fallback is unreachable by
60
- * construction — every id this sees came out of readEvents() or a projection
61
- * of it, and readEvents builds each one as `<session>#<index>`. It is here
62
- * because the readers on top of this are a dashboard `/api/pulse` polls every
63
- * 5s and two audits the operator runs on a whole log, and an id that somehow
64
- * would not parse should sort somewhere rather than take the caller down with
65
- * it. Where it lands is deliberately modest: an event whose place in the log
66
- * is unreadable does not get to win a tie on it.
67
- */
68
- function logOrderOf(eventId) {
69
- try {
70
- return parseEventId(eventId);
71
- }
72
- catch {
73
- return { sessionId: eventId, index: -1 };
74
- }
75
- }
76
- /**
77
- * The order the log wrote two events in: negative when `a` came first,
78
- * positive when `b` did, zero only for the same event.
79
- *
80
- * `ts` is stamped at millisecond resolution (appendEvent, just below), so a
81
- * burst of appends routinely shares one: the test fixture's own last two
82
- * events tie in roughly one build in three, and a gate outcome and the retry
83
- * dispatched in answer to it are written back to back. Nothing else in the
84
- * record carries the sequence — `events_raw` has no such column and the JSONL
85
- * line has no such field — so on a tie `ts` has nothing left to say, and
86
- * nothing downstream of it does either. A `>` between two tied rows is not a
87
- * decision, it is whichever the scan reached first; `ORDER BY ts` is the same
88
- * non-answer spelled in SQL, since SQLite promises nothing about tied rows and
89
- * hands them back in physical order, which changes the moment a row is
90
- * rewritten; and a JS `.sort()` whose comparator returns 0 throughout is
91
- * stable, so it keeps that same scan order and passes it off as chronology.
92
- *
93
- * The log index behind the event id is what actually decides, and it has to be
94
- * read as a number: ordered as text — which is what `ORDER BY ts, event_id`
95
- * does — `#9` sorts after `#10`, so the tiebreaker inverts as soon as a
96
- * session's log passes ten events.
97
- *
98
- * It lives beside parseEventId rather than in any one reader because more than
99
- * one of them asks this question: the dashboard queries fold and sort rows,
100
- * escalation.ts walks a task's rounds looking for the dispatch on either side
101
- * of one. Every ordering a reader would call chronological routes through
102
- * here, so that no two of them can answer the same question about the same two
103
- * events differently — which is exactly what happened when the callers spelled
104
- * the comparison themselves, with opposite operators.
105
- */
106
- export function compareLogOrder(a, b) {
107
- if (a.ts !== b.ts)
108
- return a.ts < b.ts ? -1 : 1;
109
- const left = logOrderOf(a.eventId);
110
- const right = logOrderOf(b.eventId);
111
- if (left.sessionId !== right.sessionId)
112
- return left.sessionId < right.sessionId ? -1 : 1;
113
- return left.index - right.index;
114
- }
115
- /**
116
- * Whether `a` is the later of two events — the one a reader means by "what
117
- * just happened".
118
- */
119
- export function isLaterEvent(a, b) {
120
- return compareLogOrder(a, b) > 0;
54
+ return parsed;
121
55
  }
122
56
  /**
123
57
  * The task an event names, read from both places one can be written.
@@ -517,7 +451,7 @@ const TYPED_PAYLOAD_SCHEMAS = {
517
451
  /**
518
452
  * The one field of a `task-result-recorded` payload a reader iterates. The
519
453
  * projector writes `artifacts[]` into rows inside the session's transaction,
520
- * so an object here (csb-audit-1#100 carried `{ claude_half, external_half,
454
+ * so an object here (a dogfood run's event #100 carried `{ claude_half, external_half,
521
455
  * repair_brief }`) rolled back the whole session and the dashboard drew
522
456
  * nothing for it. Pinning only this field is deliberate: the full result
523
457
  * schema is not enforced on the event, because real logs carry many
@@ -537,8 +471,28 @@ function validateResultArtifactsShape(record) {
537
471
  errors: [{ path: '/artifacts', message: `must be an array, got ${typeof artifacts}` }],
538
472
  });
539
473
  }
474
+ /**
475
+ * `error-logged`'s task_id (D-245: envelope or payload, envelope wins) names
476
+ * exactly one task, the same rule taxonomy.ts enforces for `task_ref` — but
477
+ * `task_id` is schema-typed as a plain string, so no taxonomy rule ever sees
478
+ * it, and a comma-joined "epic-1/task-1,epic-1/task-2" passed straight
479
+ * through. db/projector.ts's fold read that string as a single task id and
480
+ * minted a phantom row for it that stayed `todo` forever. Checked here,
481
+ * beside validateResultArtifactsShape, for the same reason: the shape a
482
+ * later reader assumes is not one the event schema or taxonomy already rule
483
+ * out.
484
+ */
485
+ function validateErrorLoggedTaskIdShape(record) {
486
+ if (record.event_type !== 'error-logged')
487
+ return;
488
+ const taskId = eventTaskId(record);
489
+ if (taskId === null || !taskId.includes(','))
490
+ return;
491
+ throw new EventError('events.multi-value-task-id', `Event "error-logged" names more than one task in its task_id ("${taskId}"). Log one error-logged event per task instead.`, { event_type: record.event_type, task_id: taskId });
492
+ }
540
493
  function validateTypedPayload(schemas, taxonomy, record) {
541
494
  validateResultArtifactsShape(record);
495
+ validateErrorLoggedTaskIdShape(record);
542
496
  const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
543
497
  if (schemaName === undefined)
544
498
  return;
@@ -590,19 +544,47 @@ async function validateCausalParent(input, opts, existing) {
590
544
  throw new EventError('events.unknown-causal-parent', `causal_parent "${input.causal_parent}" does not reference an existing event in session "${parent.sessionId}" (it holds ${parentEvents.length}).`, { causal_parent: input.causal_parent, session_id: parent.sessionId });
591
545
  }
592
546
  }
547
+ // EventInput requires causal_parent, so inside the orchestrator undefined is
548
+ // unreachable. It is reachable from outside: every event appended through the
549
+ // CLI crosses a JSON boundary where the type guarantees nothing, and omitting
550
+ // a field is the likeliest way a hand-written event is malformed. This used
551
+ // to compare against `null` alone, so an omitted parent fell through into
552
+ // parseEventId(undefined) and reached the caller as a raw TypeError with no
553
+ // `code` — the one shape produced by accident was the one shape with nothing
554
+ // to branch on. Normalise once so every caller reads the same guard.
555
+ function requireCausalParentPresence(eventType, causalParent) {
556
+ if (causalParent === null && eventType !== ROOT_EVENT_TYPE) {
557
+ throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${eventType}").`, { event_type: eventType });
558
+ }
559
+ }
560
+ /**
561
+ * Validate an event's envelope — the same causal_parent checks `appendEvent`
562
+ * runs immediately before it writes — without appending anything.
563
+ *
564
+ * `queue.ts`'s `step`/`batchStep` and `integration.ts`'s check suite each do
565
+ * real work of their own (a rebase and a no-ff merge; a full check run) ahead
566
+ * of their own first append, so a bad `--causal-parent` used to surface only
567
+ * there — after the git work had already landed for real, with no event to
568
+ * show for it. A retry then found the branch already advanced and reported it
569
+ * as stuck. Calling this first, before any of that work starts, means a bad
570
+ * envelope is refused with nothing done yet to undo.
571
+ *
572
+ * Only the causal_parent rules and the session-id shape are checked here. The
573
+ * event schema, the taxonomy and typed payloads are not: a caller whose payload
574
+ * could fail those must not treat a pass as "the append will succeed".
575
+ */
576
+ export async function validateEventEnvelope(input, opts = {}) {
577
+ const causalParent = input.causal_parent ?? null;
578
+ requireCausalParentPresence(input.event_type, causalParent);
579
+ if (causalParent === null)
580
+ return;
581
+ const filePath = logPath(input.session_id, opts);
582
+ const existing = await readEventsAtPath(filePath, input.session_id);
583
+ await validateCausalParent(input, opts, existing);
584
+ }
593
585
  async function appendEventLocked(input, opts, filePath) {
594
- // EventInput requires causal_parent, so inside the orchestrator undefined is
595
- // unreachable. It is reachable from outside: every event appended through the
596
- // CLI crosses a JSON boundary where the type guarantees nothing, and omitting
597
- // a field is the likeliest way a hand-written event is malformed. Both guards
598
- // below used to compare against `null` alone, so an omitted parent fell
599
- // through them into parseEventId(undefined) and reached the caller as a raw
600
- // TypeError with no `code` — the one shape produced by accident was the one
601
- // shape with nothing to branch on. Normalise once so both guards read it.
602
586
  const causalParent = input.causal_parent ?? null;
603
- if (causalParent === null && input.event_type !== ROOT_EVENT_TYPE) {
604
- throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${input.event_type}").`, { event_type: input.event_type });
605
- }
587
+ requireCausalParentPresence(input.event_type, causalParent);
606
588
  await mkdir(path.dirname(filePath), { recursive: true });
607
589
  const existing = await readEventsAtPath(filePath, input.session_id);
608
590
  if (causalParent !== null) {
@@ -1,5 +1,5 @@
1
1
  import { claimCoversPath } from './claims.js';
2
- import { listFindings } from './findings.js';
2
+ import { listFindings, OPEN_FOR_DISPATCH } from './findings.js';
3
3
  /**
4
4
  * The spliced block's delimiters, exported for the same two reasons
5
5
  * lessons.ts exports its own: a caller composing a dispatch prompt needs to
@@ -8,18 +8,6 @@ import { listFindings } from './findings.js';
8
8
  */
9
9
  export const FINDING_BLOCK_BEGIN = '<!-- BEGIN OPEN FINDINGS -->';
10
10
  export const FINDING_BLOCK_END = '<!-- END OPEN FINDINGS -->';
11
- /**
12
- * Which statuses are "open" for the purpose of dispatch context.
13
- *
14
- * `raised` and `confirmed` only. `refuted`, `waived`, `fix-verified`,
15
- * `amended` and `expired` are closed. `fix-pending`/`fix-landed` are *already
16
- * someone's assignment* — surfacing one to a second coder is how two diffs end
17
- * up fixing one finding, and the second one lands as an unrequested change.
18
- * `amend-pending` (D-127) is open elsewhere but not here for that same reason:
19
- * it already names the tasks that discharge it, and the fix belongs in those
20
- * tasks' diffs, not in whatever task is being dispatched now.
21
- */
22
- export const OPEN_FOR_DISPATCH = ['raised', 'confirmed'];
23
11
  /**
24
12
  * Prompt-facing text for one field. Same two guarantees as lessons.ts's
25
13
  * `flatten`, and for the same reason — this text is going INTO a prompt: